1use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use std::cell::{Cell, RefCell};
12use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
13
14use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
15use rusqlite::{Connection, OpenFlags, OptionalExtension};
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18use sha2::{Digest, Sha256};
19use ssp2::decode::WIRE_VERSION;
20use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
21use ssp2::primitives::RawJson;
22use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
23use ssp2::{
24 decode_message, encode_message, encode_presence_publish, parse_control, ControlMessage,
25 PresenceKind,
26};
27
28use crate::api::{
29 ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30 ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31 ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
32 CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
33 CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
34 DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters, DiagnosticSubscription,
35 FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget,
36 LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation, PresencePeer,
37 PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot, QueryValue,
38 RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow, RowState,
39 SchemaFloor, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot,
40 TableChange, WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
41 CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
42 MAX_DIAGNOSTIC_QUERY_FAILURES,
43};
44use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
45#[cfg(feature = "bench-internals")]
46use crate::bench::{Phase, Recorder};
47use crate::previous_version::{
48 capture_previous_version_from_replica, first_incompatibility,
49 reconcile_previous_version_at_boot, set_local_schema_descriptor,
50 sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
51 PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
52};
53use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
54use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
55use crate::values::{
56 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
57 encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
58 normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
59};
60
61const DEFAULT_ACCEPT: u8 = 0b0111;
65const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
66const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
67const ACCEPT_SQLITE: u8 = 1 << 2;
68const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
69const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
70
71pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
73const LOCAL_REVISION_KEY: &str = "localRevision";
74const CLIENT_ID_KEY: &str = "clientId";
75const LEASE_STATE_KEY: &str = "leaseState";
76const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
77const LOG_EPOCH_KEY: &str = "logEpoch";
78const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
83const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
84const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
85const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
88const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
91
92#[derive(Debug, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase", deny_unknown_fields)]
94struct PersistedLocalDataRebootstrapReceipt {
95 version: u8,
96 retained_commits: u64,
97 reset_subscriptions: u64,
98}
99
100fn invalid_local_rebootstrap_receipt() -> String {
101 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
102}
103
104fn encode_local_rebootstrap_receipt(
105 retained_commits: usize,
106 reset_subscriptions: usize,
107) -> Result<String, String> {
108 let retained_commits =
109 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
110 let reset_subscriptions =
111 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
112 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
113 return Err(invalid_local_rebootstrap_receipt());
114 }
115 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
116 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
117 retained_commits,
118 reset_subscriptions,
119 })
120 .map_err(|_| invalid_local_rebootstrap_receipt())
121}
122
123fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
124 if value == "v1" {
126 return Ok((0, 0));
127 }
128 let receipt: PersistedLocalDataRebootstrapReceipt =
129 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
130 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
131 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
132 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
133 {
134 return Err(invalid_local_rebootstrap_receipt());
135 }
136 Ok((
137 usize::try_from(receipt.retained_commits)
138 .map_err(|_| invalid_local_rebootstrap_receipt())?,
139 usize::try_from(receipt.reset_subscriptions)
140 .map_err(|_| invalid_local_rebootstrap_receipt())?,
141 ))
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145enum SubState {
146 Active,
147 Revoked,
148 Failed,
149}
150
151#[cfg(test)]
152mod observation_tests {
153 use super::*;
154 use crate::native_transport::HostTransport;
155 use crate::values::encode_row_json;
156 use serde_json::json;
157
158 fn client() -> SyncClient {
159 SyncClient::new(
160 "retry-test".to_owned(),
161 &json!({
162 "version": 1,
163 "tables": [{
164 "name": "tasks",
165 "primaryKey": "id",
166 "columns": [
167 { "name": "id", "type": "string", "nullable": false },
168 { "name": "project_id", "type": "string", "nullable": false }
169 ],
170 "scopes": [{ "pattern": "project:{project_id}" }]
171 }]
172 }),
173 ClientLimits::default(),
174 )
175 .expect("test client")
176 }
177
178 #[cfg(feature = "e2ee")]
179 #[test]
180 fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
181 let schema = json!({
182 "version": 1,
183 "tables": [{
184 "name": "secrets",
185 "primaryKey": "id",
186 "columns": [
187 { "name": "id", "type": "string", "nullable": false },
188 { "name": "encryption_key_id", "type": "string", "nullable": true },
189 { "name": "note", "type": "bytes", "nullable": true,
190 "encrypted": true, "declaredType": "string" }
191 ],
192 "scopes": []
193 }]
194 });
195 let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
196 .expect("test client");
197 let mut config = crate::values::EncryptionConfig::default();
198 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
199 config
200 .key_id_columns
201 .insert("secrets".to_owned(), "encryption_key_id".to_owned());
202 client.set_encryption(config);
203 client
206 .write_base_row(
207 "secrets",
208 &vec![Some(ColumnValue::String("ghost".into())), None, None],
209 1,
210 )
211 .unwrap();
212 client.rebuild_overlay();
213 let commit_id = client
214 .patch(
215 "secrets",
216 "ghost",
217 Map::from_iter([("note".to_owned(), json!("x"))]),
218 None,
219 )
220 .expect("patch records without an author-time encrypt failure");
221 assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
222 assert!(client.drop_unencodable_outbox().unwrap());
224 assert!(client.pending_commit_ids().is_empty());
225 let rejections = client.rejections();
226 assert_eq!(rejections.len(), 1);
227 assert_eq!(rejections[0].code, "client.encrypt_failed");
228 assert_eq!(rejections[0].client_commit_id, commit_id);
229 assert_eq!(
230 client
231 .commit_outcome(&commit_id)
232 .unwrap()
233 .map(|outcome| outcome.status),
234 Some(CommitOutcomeStatus::Rejected)
235 );
236 }
237
238 #[test]
239 fn patch_with_a_scope_column_matches_the_stored_local_row() {
240 let mut client = client();
241 client.create_synced_tables().unwrap();
242 client
243 .conn
244 .execute(
245 "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
246 [],
247 )
248 .unwrap();
249
250 client
253 .patch(
254 "tasks",
255 "t1",
256 Map::from_iter([
257 ("project_id".to_owned(), json!("p1")),
258 ("id".to_owned(), json!("t1")),
259 ]),
260 None,
261 )
262 .expect("an equal scope column is a no-op");
263 let values = client.outbox[0].ops[0]
264 .values
265 .clone()
266 .expect("the patch records values");
267 assert_eq!(values.get("id"), Some(&json!("t1")));
268 assert!(!values.contains_key("project_id"));
269
270 let differing = client.patch(
272 "tasks",
273 "t1",
274 Map::from_iter([("project_id".to_owned(), json!("p2"))]),
275 None,
276 );
277 assert!(differing
278 .expect_err("a differing scope column is rejected")
279 .contains("patch cannot write scope column"));
280
281 let absent = client.patch(
284 "tasks",
285 "ghost",
286 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
287 None,
288 );
289 assert!(absent
290 .expect_err("an absent local row is rejected")
291 .starts_with("sync.row_missing:"));
292 assert_eq!(client.pending_commit_ids().len(), 1);
293 }
294
295 #[test]
296 fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
297 let schema = json!({
298 "version": 1,
299 "tables": [
300 {
301 "name": "tenants",
302 "primaryKey": "tenant_id",
303 "columns": [
304 { "name": "tenant_id", "type": "string", "nullable": false },
305 { "name": "body", "type": "string", "nullable": false }
306 ],
307 "scopes": [{ "pattern": "tenant:{tenant_id}" }]
308 },
309 {
310 "name": "buckets",
311 "primaryKey": "id",
312 "columns": [
313 { "name": "id", "type": "string", "nullable": false },
314 { "name": "bucket", "type": "float", "nullable": false },
315 { "name": "body", "type": "string", "nullable": false }
316 ],
317 "scopes": [{ "pattern": "bucket:{bucket}" }]
318 }
319 ]
320 });
321 let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
322 .expect("test client");
323 client.create_synced_tables().unwrap();
324
325 client
327 .conn
328 .execute(
329 "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
330 [],
331 )
332 .unwrap();
333 client
334 .patch(
335 "tenants",
336 "t1",
337 Map::from_iter([
338 ("tenant_id".to_owned(), json!("t1")),
339 ("body".to_owned(), json!("created")),
340 ]),
341 None,
342 )
343 .expect("the primary-key scope column is proven equal by construction");
344
345 client
347 .conn
348 .execute(
349 "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
350 [],
351 )
352 .unwrap();
353 client
354 .patch(
355 "buckets",
356 "b1",
357 Map::from_iter([
358 ("bucket".to_owned(), json!(2)),
359 ("body".to_owned(), json!("coerced")),
360 ]),
361 None,
362 )
363 .expect("the stored REAL value coerces from a supplied integer");
364 let values = client.outbox[1].ops[0]
365 .values
366 .clone()
367 .expect("the patch records values");
368 assert!(!values.contains_key("bucket"));
369
370 let differing = client.patch(
372 "buckets",
373 "b1",
374 Map::from_iter([("bucket".to_owned(), json!(3))]),
375 None,
376 );
377 assert!(differing
378 .expect_err("a differing scope value is rejected")
379 .contains("patch cannot write scope column"));
380 }
381
382 #[cfg(feature = "e2ee")]
383 #[test]
384 fn stored_key_fallback_resolves_an_integer_primary_key() {
385 let schema = json!({
386 "version": 1,
387 "tables": [{
388 "name": "patients",
389 "primaryKey": "id",
390 "columns": [
391 { "name": "id", "type": "integer", "nullable": false },
392 { "name": "encryption_key_id", "type": "string", "nullable": true },
393 { "name": "note", "type": "bytes", "nullable": true,
394 "encrypted": true, "declaredType": "string" }
395 ],
396 "scopes": []
397 }]
398 });
399 let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
400 .expect("test client");
401 client.create_synced_tables().unwrap();
402 client
403 .conn
404 .execute(
405 "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
406 VALUES (5, 'k1', NULL, 1)",
407 [],
408 )
409 .unwrap();
410 let mut config = crate::values::EncryptionConfig::default();
411 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
412 config
413 .key_id_columns
414 .insert("patients".to_owned(), "encryption_key_id".to_owned());
415 client.set_encryption(config);
416 let table = client.schema.table("patients").unwrap();
417 let fallback = client
418 .stored_key_fallback(table, "5")
419 .expect("the stored integer key resolves the fallback");
420 assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
421 }
422
423 #[test]
424 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
425 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
426 {"name":"id","type":"string","nullable":false},
427 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
428 for state in ["absent", "cached", "pinned"] {
429 let path = std::env::temp_dir().join(format!(
430 "syncular-blob-stage-{}.sqlite",
431 uuid::Uuid::new_v4()
432 ));
433 let mut client = SyncClient::open_path(
434 "stage-test".into(),
435 &schema,
436 Default::default(),
437 path.to_str().unwrap(),
438 )
439 .unwrap();
440 let bytes = b"atomic stage";
441 if state != "absent" {
442 client
443 .upload_blob(bytes, Some("text/plain".into()), None)
444 .unwrap();
445 if state == "cached" {
446 client
447 .conn
448 .execute("DELETE FROM _syncular_blob_uploads", [])
449 .unwrap();
450 }
451 }
452 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
453 client
454 .conn
455 .execute_batch(
456 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
457 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
458 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
459 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
460 )
461 .unwrap();
462 let error = client
463 .upload_blob(bytes, Some("application/octet-stream".into()), None)
464 .unwrap_err();
465 assert!(error.contains("injected stage failure"), "{state}: {error}");
466 assert!(client.conn.is_autocommit());
467 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
468 drop(client);
469 let mut reopened = SyncClient::open_path(
470 "stage-test".into(),
471 &schema,
472 Default::default(),
473 path.to_str().unwrap(),
474 )
475 .unwrap();
476 let durable = reopened
477 .query("SELECT * FROM _syncular_blobs", &[])
478 .unwrap();
479 reopened
480 .conn
481 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
482 .unwrap();
483 let retried = reopened.upload_blob(bytes, None, None).unwrap();
484 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
485 assert_eq!(
486 reopened
487 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
488 .unwrap(),
489 vec![Map::from_iter([(
490 "blob_id".into(),
491 Value::from(blob_id_for(bytes)),
492 )])]
493 );
494 drop(reopened);
495 std::fs::remove_file(&path).unwrap();
496 assert_eq!(durable, bodies, "{state}: reopened body state");
497 assert_eq!(immediate, bodies, "{state}: immediate body state");
498 }
499 }
500
501 #[test]
502 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
503 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
504 {"name":"id","type":"string","nullable":false},
505 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
506 for fault in [
507 "missing",
508 "body-type",
509 "length",
510 "hash",
511 "pin-metadata",
512 "read",
513 "pin-delete",
514 ] {
515 let path = std::env::temp_dir().join(format!(
516 "syncular-blob-upload-{}.sqlite",
517 uuid::Uuid::new_v4()
518 ));
519 let mut client = SyncClient::open_path(
520 "upload-test".into(),
521 &schema,
522 Default::default(),
523 path.to_str().unwrap(),
524 )
525 .unwrap();
526 let bytes = b"durable upload";
527 let ref_value = client
528 .upload_blob(bytes, Some("text/plain".into()), None)
529 .unwrap();
530 let commit = client
531 .mutate(vec![Mutation::Upsert {
532 table: "attachments".into(),
533 values: Map::from_iter([
534 ("id".into(), Value::from("pending")),
535 (
536 "file".into(),
537 Value::from(format!(
538 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
539 blob_id_for(bytes),
540 bytes.len()
541 )),
542 ),
543 ]),
544 base_version: None,
545 }])
546 .unwrap();
547 client
548 .conn
549 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
550 .unwrap();
551 client.conn.execute_batch(match fault {
552 "missing" => "DELETE FROM _syncular_blobs",
553 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
554 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
555 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
556 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
557 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
558 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
559 }).unwrap();
560 let state = if fault == "read" {
561 Vec::new()
562 } else {
563 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
564 };
565 let mut transport = CountingRealtimeTransport::default();
566 let error = client.flush_blob_uploads(&mut transport).unwrap_err();
567 assert_eq!(
568 error.code,
569 if matches!(fault, "read" | "pin-delete") {
570 "client.failed"
571 } else {
572 "sync.local_corrupt"
573 },
574 "{fault}"
575 );
576 assert_eq!(
577 transport.blob_uploads.len(),
578 usize::from(fault == "pin-delete"),
579 "{fault}: corrupt bytes must never be uploaded"
580 );
581 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
582 if fault != "read" {
583 assert_eq!(
584 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
585 state
586 );
587 } else {
588 client
589 .conn
590 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
591 .unwrap();
592 }
593 drop(client);
594 let mut reopened = SyncClient::open_path(
595 "upload-test".into(),
596 &schema,
597 Default::default(),
598 path.to_str().unwrap(),
599 )
600 .unwrap();
601 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
602 if fault != "read" {
603 assert_eq!(
604 reopened
605 .query("SELECT * FROM _syncular_blobs", &[])
606 .unwrap(),
607 state
608 );
609 }
610 if fault == "pin-delete" {
611 reopened
612 .conn
613 .execute_batch("DROP TRIGGER fail_pin_delete")
614 .unwrap();
615 }
616 if fault == "pin-metadata" {
617 reopened
618 .conn
619 .execute(
620 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
621 [],
622 )
623 .unwrap();
624 }
625 reopened
626 .conn
627 .execute_batch(
628 "DELETE FROM _syncular_blobs;
629 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
630 )
631 .unwrap();
632 transport.blob_uploads.clear();
633 reopened.flush_blob_uploads(&mut transport).unwrap();
634 assert_eq!(
635 transport.blob_uploads,
636 [(blob_id_for(bytes), bytes.to_vec())]
637 );
638 assert_eq!(
639 reopened
640 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
641 .unwrap(),
642 Vec::<Map<String, Value>>::new()
643 );
644 assert_eq!(reopened.pending_commit_ids(), [commit]);
645 assert_eq!(
646 reopened
647 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
648 .unwrap()
649 .unwrap()
650 .bytes,
651 bytes
652 );
653 drop(reopened);
654 std::fs::remove_file(path).unwrap();
655 }
656 }
657
658 #[test]
659 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
660 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
661 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
662 "scopes":[{"pattern":"project:{project_id}"}]}]});
663 for realtime in [false, true] {
664 for fault in [
665 "none",
666 "row",
667 "revision",
668 "commit",
669 "cursor",
670 "error",
671 "missing-end",
672 ] {
673 if realtime && fault == "missing-end" {
674 continue;
675 }
676 let path = std::env::temp_dir()
677 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
678 let mut client = SyncClient::open_path(
679 "frame-test".into(),
680 &schema,
681 Default::default(),
682 path.to_str().unwrap(),
683 )
684 .unwrap();
685 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
686 client
687 .subscribe(
688 "tasks".into(),
689 "tasks".into(),
690 vec![("project_id".into(), vec!["p1".into()])],
691 None,
692 )
693 .unwrap();
694 client.subs[0].cursor = 0;
695 client.subs[0].synced_once = true;
696 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
697 client.persist_sub(&client.subs[0]).unwrap();
698 client.realtime_state = RealtimeState::Connected;
699 client.realtime_reason_code = None;
700 client.drain_change_batches();
701 let mut transport = CountingRealtimeTransport::default();
702 let (_, meta) = client.build_request(false);
703 let mut response = Message {
704 wire_version: WIRE_VERSION,
705 msg_kind: MsgKind::Response,
706 frames: vec![
707 Frame::RespHeader {
708 required_schema_version: None,
709 latest_schema_version: None,
710 log_epoch: Some("epoch-1".into()),
711 reset_required: Some(false),
712 },
713 Frame::SubStart {
714 id: "tasks".into(),
715 status: SubStatus::Active,
716 reason_code: "".into(),
717 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
718 bootstrap: false,
719 },
720 ],
721 };
722 for (index, id) in ["first", "second"].iter().enumerate() {
723 let values = Map::from_iter([
724 ("id".into(), Value::from(*id)),
725 ("project_id".into(), Value::from("p1")),
726 ]);
727 response.frames.push(Frame::Commit {
728 commit_seq: index as i64 + 1,
729 actor_id: "writer".into(),
730 created_at_ms: 1,
731 tables: vec!["tasks".into()],
732 changes: vec![ssp2::model::Change {
733 table_index: 0,
734 row_id: (*id).into(),
735 op: Op::Upsert,
736 row_version: Some(index as i64 + 1),
737 scopes: vec![("project_id".into(), "p1".into())],
738 row: Some(
739 encode_row_json(
740 client.schema.table("tasks").unwrap(),
741 id,
742 &values,
743 &client.encryption,
744 )
745 .unwrap(),
746 ),
747 }],
748 });
749 }
750 response.frames.push(Frame::SubEnd {
751 next_cursor: 2,
752 bootstrap_state: None,
753 });
754 let valid = response.clone();
755 match fault {
756 "row" => {
757 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
758 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
759 },
760 "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(),
761 "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(),
762 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
763 "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});},
764 "missing-end" => {response.frames.truncate(3);},
765 _ => {}
766 }
767 if realtime {
768 client.on_realtime_binary(&mut transport, &encode_message(&response));
769 } else {
770 assert_eq!(
771 matches!(
772 client.process_response(&mut transport, response, &meta),
773 SyncOutcome::Ok(_)
774 ),
775 fault == "none",
776 "{fault}"
777 );
778 }
779 assert!(
780 client.conn.is_autocommit(),
781 "no open transaction after {realtime}/{fault}"
782 );
783 let count = if matches!(fault, "none" | "cursor") {
784 2
785 } else {
786 1
787 };
788 let expected_revision = count + u64::from(realtime && fault != "none");
789 assert_eq!(
790 client.local_revision(),
791 expected_revision,
792 "{realtime}/{fault}"
793 );
794 let batches = client.drain_change_batches();
795 assert_eq!(
796 batches.len(),
797 expected_revision as usize,
798 "{realtime}/{fault}"
799 );
800 let row_batches: Vec<_> = batches
801 .iter()
802 .filter(|batch| !batch.tables.is_empty())
803 .collect();
804 assert_eq!(row_batches.len(), count as usize);
805 assert_eq!(
806 row_batches
807 .iter()
808 .map(|b| b.revision.clone())
809 .collect::<Vec<_>>(),
810 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
811 );
812 for table in ["tasks", "_syncular_base_tasks"] {
813 assert_eq!(
814 client
815 .conn
816 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
817 .get::<_, u64>(0))
818 .unwrap(),
819 count,
820 "{realtime}/{fault}/{table}"
821 );
822 }
823 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
824 assert_eq!(
825 transport
826 .messages
827 .iter()
828 .filter(|m| m.contains("\"ack\""))
829 .count(),
830 usize::from(fault == "none")
831 );
832 if matches!(fault, "revision" | "commit" | "cursor") {
833 client
834 .conn
835 .execute_batch("DROP TRIGGER fail_frame")
836 .unwrap();
837 }
838 drop(client);
839 let mut reopened = SyncClient::open_path(
840 "frame-test".into(),
841 &schema,
842 Default::default(),
843 path.to_str().unwrap(),
844 )
845 .unwrap();
846 assert_eq!(reopened.local_revision(), expected_revision);
847 assert_eq!(
848 reopened
849 .query("SELECT id FROM tasks ORDER BY id", &[])
850 .unwrap()
851 .len(),
852 count as usize
853 );
854 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
855 let (_, meta) = reopened.build_request(false);
856 assert!(matches!(
857 reopened.process_response(&mut transport, valid, &meta),
858 SyncOutcome::Ok(_)
859 ));
860 assert_eq!(
861 reopened
862 .query("SELECT id FROM tasks ORDER BY id", &[])
863 .unwrap()
864 .len(),
865 2
866 );
867 assert_eq!(reopened.subs[0].cursor, 2);
868 drop(reopened);
869 std::fs::remove_file(path).unwrap();
870 }
871 }
872 }
873
874 #[test]
875 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
876 for status in [SubStatus::Reset, SubStatus::Revoked] {
877 for fault in ["subscription", "revision"] {
878 let mut client = client();
879 client
880 .subscribe(
881 "tasks".into(),
882 "tasks".into(),
883 vec![("project_id".into(), vec!["p1".into()])],
884 None,
885 )
886 .unwrap();
887 client.subs[0].cursor = 41;
888 client.subs[0].synced_once = true;
889 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
890 client.persist_sub(&client.subs[0]).unwrap();
891 client
892 .mutate(vec![Mutation::Upsert {
893 table: "tasks".into(),
894 values: Map::from_iter([
895 ("id".into(), Value::from("pending")),
896 ("project_id".into(), Value::from("p1")),
897 ]),
898 base_version: None,
899 }])
900 .unwrap();
901 let ids = client.pending_commit_ids();
902 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
903 let revision = client.local_revision();
904 client.drain_change_batches();
905 let (_, meta) = client.build_request(false);
906 client.conn.execute_batch(if fault == "subscription" {
907 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
908 } else {
909 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
910 }).unwrap();
911 let mut transport = CountingRealtimeTransport::default();
912 let mut report = SyncReport::default();
913 if status == SubStatus::Reset {
915 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
916 }
917 let result = client.process_section(
918 &mut transport,
919 "tasks",
920 status,
921 "sync.scope_revoked",
922 vec![],
923 vec![],
924 Some((42, None)),
925 &meta,
926 &mut report,
927 );
928 assert!(result.is_err(), "{status:?}/{fault}");
929 assert!(client.conn.is_autocommit());
930 assert_eq!(client.subs[0].cursor, 41);
931 assert_eq!(client.subs[0].state, SubState::Active);
932 assert_eq!(client.pending_commit_ids(), ids);
933 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
934 assert!(client.rejections.is_empty());
935 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
936 assert_eq!(client.local_revision(), revision);
937 assert!(client.drain_change_batches().is_empty());
938 assert!(report.revoked.is_empty() && report.resets.is_empty());
939 }
940 }
941 }
942
943 #[test]
944 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
945 let mut client = client();
946 client
947 .subscribe(
948 "tasks".into(),
949 "tasks".into(),
950 vec![("project_id".into(), vec!["p1".into()])],
951 None,
952 )
953 .unwrap();
954 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
955 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
956 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
957 let row = |id: &str| ssp2::segment::SegmentRow {
958 server_version: 1,
959 values: vec![
960 Some(ColumnValue::String(id.into())),
961 Some(ColumnValue::String("p1".into())),
962 ],
963 };
964 let segment = RowsSegment {
965 table: "tasks".into(),
966 schema_version: 1,
967 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
968 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
969 };
970 assert!(client.apply_segment(0, &segment, true).is_err());
971 assert!(client.conn.is_autocommit());
972 assert_eq!(
973 client
974 .query("SELECT id FROM tasks ORDER BY id", &[])
975 .unwrap(),
976 vec![Map::from_iter([("id".into(), Value::from("first"))])]
977 );
978 assert_eq!(client.local_revision(), 1);
979 assert_eq!(client.drain_change_batches().len(), 1);
980 assert_eq!(client.subs[0].cursor, -1);
981 client
982 .conn
983 .execute_batch("DROP TRIGGER fail_block")
984 .unwrap();
985 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
986 assert_eq!(client.local_revision(), 3);
987 assert_eq!(client.drain_change_batches().len(), 2);
988 }
989
990 #[test]
991 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
992 assert_eq!(
993 client().diagnostics_storage().blob_cache_bytes_approx,
994 Some(0)
995 );
996 let client = SyncClient::new(
997 "diagnostic-storage".to_owned(),
998 &json!({
999 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1000 "columns": [{"name": "id", "type": "string", "nullable": false},
1001 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1002 }),
1003 ClientLimits {
1004 blob_cache_max_bytes: Some(5),
1005 ..Default::default()
1006 },
1007 )
1008 .unwrap();
1009 let empty = client.diagnostics_storage();
1010 assert_eq!(empty.status, "healthy");
1011 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1012 assert_eq!(empty.retained_outcome_entries, Some(0));
1013 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1014 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1015 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1016 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1017 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1018 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1019 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1020 let written = client.diagnostics_storage();
1021 assert_eq!(written.status, "pressure");
1022 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1023 assert_eq!(written.retained_outcome_entries, Some(2));
1024 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1025 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1026 assert_eq!(
1027 written.pressure_reason_code.as_deref(),
1028 Some("client.blob_cache_over_limit")
1029 );
1030 client
1031 .conn
1032 .execute_batch(
1033 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1034 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1035 )
1036 .unwrap();
1037 let staged = client.diagnostics_storage();
1038 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1039 assert_eq!(staged.retained_outcome_entries, Some(0));
1040 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1041 assert_eq!(staged.status, "healthy");
1042 client.conn.execute_batch("ROLLBACK").unwrap();
1043 assert_eq!(client.diagnostics_storage(), written);
1044 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1045 let unreadable = client.diagnostics_storage();
1046 assert_eq!(unreadable.status, "unreadable");
1047 assert!(unreadable.database_bytes_approx.is_none());
1048 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1049 assert!(unreadable.retained_outcome_entries.is_none());
1050 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1051 assert!(unreadable.blob_cache_bytes_approx.is_none());
1052 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1053 let restored = client.diagnostics_storage();
1054 assert_eq!(restored.status, written.status);
1055 assert_eq!(
1056 restored.retained_outcome_bytes_approx,
1057 written.retained_outcome_bytes_approx
1058 );
1059 client.conn.set_prepared_statement_cache_capacity(0);
1060 assert_eq!(client.diagnostics_storage(), restored);
1061 }
1062
1063 struct ServerErrorTransport {
1065 code: &'static str,
1066 retryable: bool,
1067 }
1068
1069 impl Transport for ServerErrorTransport {
1070 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1071 Ok(encode_message(&Message {
1072 wire_version: WIRE_VERSION,
1073 msg_kind: MsgKind::Response,
1074 frames: vec![
1075 Frame::RespHeader {
1076 required_schema_version: None,
1077 latest_schema_version: None,
1078 log_epoch: Some("epoch-1".to_owned()),
1079 reset_required: Some(false),
1080 },
1081 Frame::Error {
1082 code: self.code.to_owned(),
1083 message: "server error".to_owned(),
1084 category: "internal".to_owned(),
1085 retryable: self.retryable,
1086 recommended_action: "retryLater".to_owned(),
1087 details: None,
1088 },
1089 ],
1090 }))
1091 }
1092
1093 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1094 self.sync(request)
1095 }
1096
1097 fn download_segment(
1098 &mut self,
1099 _request: &SegmentRequest,
1100 _on_progress: &mut dyn FnMut(u64),
1101 ) -> Result<Vec<u8>, TransportError> {
1102 Err(TransportError::new("sync.transport_failed", "offline"))
1103 }
1104
1105 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1106 Ok(())
1107 }
1108
1109 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1110 Ok(())
1111 }
1112
1113 fn realtime_close(&mut self) -> Result<(), TransportError> {
1114 Ok(())
1115 }
1116 }
1117
1118 #[test]
1119 fn retryable_server_error_schedules_a_background_retry() {
1120 let mut client = client();
1121 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1122 let mut transport = ServerErrorTransport {
1123 code: "sync.internal_error",
1124 retryable: true,
1125 };
1126 assert!(matches!(
1127 client.sync(&mut transport),
1128 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1129 ));
1130 assert!(matches!(
1131 client.drain_sync_intents().as_slice(),
1132 [SyncIntent::Background { delay_ms: 250 }]
1133 ));
1134 assert_eq!(
1135 client.progress().snapshot().unwrap().retry_delay_ms,
1136 Some(250)
1137 );
1138 let mut transport = ServerErrorTransport {
1139 code: "sync.invalid_request",
1140 retryable: false,
1141 };
1142 assert!(matches!(
1143 client.sync(&mut transport),
1144 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1145 ));
1146 assert!(client.drain_sync_intents().is_empty());
1147 let progress = client.progress().snapshot().unwrap();
1148 assert_eq!(progress.state, ProgressState::Failed);
1149 assert_eq!(progress.retry_delay_ms, None);
1150 }
1151
1152 #[test]
1153 fn failed_progress_retry_delay_doubles_to_the_cap() {
1154 let mut client = client();
1155 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1156 let mut transport = ServerErrorTransport {
1157 code: "sync.internal_error",
1158 retryable: true,
1159 };
1160 let delays: Vec<Option<u64>> = (0..9)
1161 .map(|_| {
1162 client.sync(&mut transport);
1163 client.progress().snapshot().unwrap().retry_delay_ms
1164 })
1165 .collect();
1166 assert_eq!(
1167 delays,
1168 [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
1169 );
1170 }
1171
1172 #[test]
1173 fn background_retry_deadlines_back_off_and_reset() {
1174 let mut client = client();
1175 client.schedule_background_retry();
1176 assert!(matches!(
1177 client.drain_sync_intents().as_slice(),
1178 [SyncIntent::Background { delay_ms: 250 }]
1179 ));
1180 client.schedule_background_retry();
1181 assert!(matches!(
1182 client.drain_sync_intents().as_slice(),
1183 [SyncIntent::Background { delay_ms: 500 }]
1184 ));
1185 client.reset_background_retry();
1186 client.schedule_background_retry();
1187 assert!(matches!(
1188 client.drain_sync_intents().as_slice(),
1189 [SyncIntent::Background { delay_ms: 250 }]
1190 ));
1191 }
1192
1193 #[test]
1194 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1195 let mut client = client();
1196 client
1197 .subscribe(
1198 "epoch-tasks".to_owned(),
1199 "tasks".to_owned(),
1200 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1201 None,
1202 )
1203 .expect("subscribe");
1204 client.drain_sync_intents();
1205
1206 client
1207 .run_log_epoch_reset("epoch-2")
1208 .expect("reset partition log epoch");
1209
1210 assert!(matches!(
1211 client.drain_sync_intents().as_slice(),
1212 [SyncIntent::Interactive]
1213 ));
1214 }
1215
1216 #[derive(Default)]
1217 struct CountingRealtimeTransport {
1218 connects: usize,
1219 closes: usize,
1220 messages: Vec<String>,
1221 blob_uploads: Vec<(String, Vec<u8>)>,
1222 blob_download: Option<Vec<u8>>,
1223 sync_calls: usize,
1224 realtime_sync_calls: usize,
1225 refuse_connect: bool,
1226 }
1227
1228 impl Transport for CountingRealtimeTransport {
1229 fn blob_upload(
1230 &mut self,
1231 blob_id: &str,
1232 bytes: &[u8],
1233 _media_type: Option<&str>,
1234 ) -> Result<(), TransportError> {
1235 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1236 Ok(())
1237 }
1238
1239 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1240 self.blob_download
1241 .clone()
1242 .map(BlobDownload::Bytes)
1243 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1244 }
1245
1246 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1247 self.sync_calls += 1;
1248 Err(TransportError::new("sync.transport_failed", "offline"))
1249 }
1250
1251 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1252 self.realtime_sync_calls += 1;
1253 Err(TransportError::new("sync.transport_failed", "offline"))
1254 }
1255
1256 fn download_segment(
1257 &mut self,
1258 _request: &SegmentRequest,
1259 _on_progress: &mut dyn FnMut(u64),
1260 ) -> Result<Vec<u8>, TransportError> {
1261 Err(TransportError::new("sync.transport_failed", "offline"))
1262 }
1263
1264 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1265 self.connects += 1;
1266 if self.refuse_connect {
1267 return Err(TransportError::new("transport.failed", "refused"));
1268 }
1269 Ok(())
1270 }
1271
1272 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1273 self.messages.push(text.to_owned());
1274 Ok(())
1275 }
1276
1277 fn realtime_close(&mut self) -> Result<(), TransportError> {
1278 self.closes += 1;
1279 Ok(())
1280 }
1281 }
1282
1283 #[test]
1284 fn blob_results_own_downloaded_bytes() {
1285 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1286 {"name":"id","type":"string","nullable":false},
1287 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1288 let bytes = b"owned typed result".to_vec();
1289 let blob_id = blob_id_for(&bytes);
1290 let mut transport = CountingRealtimeTransport {
1291 blob_download: Some(bytes.clone()),
1292 ..Default::default()
1293 };
1294 let result = {
1295 let mut client =
1296 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1297 .unwrap();
1298 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1299 assert_eq!(result.blob_id, blob_id);
1300 assert_eq!(result.byte_length, bytes.len() as i64);
1301 assert_eq!(result.bytes, bytes);
1302 assert_eq!(result.media_type, None);
1303 assert_eq!(
1304 client
1305 .conn
1306 .query_row(
1307 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1308 rusqlite::params![blob_id],
1309 |row| row.get::<_, i64>(0),
1310 )
1311 .unwrap(),
1312 0
1313 );
1314 result
1315 };
1316 assert_eq!(result.bytes, bytes);
1317
1318 let mut client =
1319 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1320 assert_eq!(
1321 client
1322 .fetch_blob_bytes(&mut transport, "not json")
1323 .unwrap_err(),
1324 (
1325 "client.failed".to_owned(),
1326 "blob ref is not JSON".to_owned()
1327 )
1328 );
1329 }
1330
1331 #[test]
1332 fn visible_blob_references_survive_cache_trimming() {
1333 let schema = json!({"version":1,"tables":[
1334 {"name":"attachments","primaryKey":"id","columns":[
1335 {"name":"id","type":"string","nullable":false},
1336 {"name":"body","type":"blob_ref","nullable":true},
1337 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1338 {"name":"avatars","primaryKey":"id","columns":[
1339 {"name":"id","type":"string","nullable":false},
1340 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1341 ]});
1342 let bytes = b"referenced above cap".to_vec();
1343 let blob_id = blob_id_for(&bytes);
1344 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1345 let mut client = SyncClient::new(
1346 "visible-blob-refs".to_owned(),
1347 &schema,
1348 ClientLimits {
1349 blob_cache_max_bytes: Some(1),
1350 ..Default::default()
1351 },
1352 )
1353 .unwrap();
1354 client.conn.execute(
1355 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1356 rusqlite::params![reference, reference, reference, reference],
1357 ).unwrap();
1358 client
1359 .conn
1360 .execute(
1361 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1362 rusqlite::params![reference],
1363 )
1364 .unwrap();
1365 client.conn.execute(
1366 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1367 [],
1368 ).unwrap();
1369 let mut transport = CountingRealtimeTransport {
1370 blob_download: Some(bytes.clone()),
1371 ..Default::default()
1372 };
1373
1374 assert_eq!(
1375 client
1376 .fetch_blob_bytes(&mut transport, &blob_id)
1377 .unwrap()
1378 .bytes,
1379 bytes
1380 );
1381 let rows = client
1382 .conn
1383 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1384 .unwrap()
1385 .query_map([], |row| row.get::<_, String>(0))
1386 .unwrap()
1387 .collect::<Result<Vec<_>, _>>()
1388 .unwrap();
1389 assert_eq!(rows, vec![blob_id]);
1390 }
1391
1392 #[test]
1393 fn realtime_connection_ownership_is_idempotent() {
1394 let mut client = client();
1395 let mut transport = CountingRealtimeTransport {
1396 connects: 0,
1397 closes: 0,
1398 ..Default::default()
1399 };
1400 client
1401 .connect_realtime(&mut transport)
1402 .expect("first connect");
1403 client
1404 .connect_realtime(&mut transport)
1405 .expect("idempotent connect");
1406 assert_eq!(transport.connects, 1);
1407 client.disconnect_realtime(&mut transport);
1408 client.disconnect_realtime(&mut transport);
1409 assert_eq!(transport.closes, 1);
1410 client
1411 .connect_realtime(&mut transport)
1412 .expect("deliberate reconnect");
1413 assert_eq!(transport.connects, 2);
1414 }
1415
1416 #[test]
1417 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1418 let mut client = client();
1419 client
1420 .set_realtime_policy(RealtimePolicy::Required)
1421 .expect("set policy");
1422 let mut transport = CountingRealtimeTransport::default();
1423 match client.sync(&mut transport) {
1424 SyncOutcome::RealtimeUnavailable {
1425 state,
1426 reason_code,
1427 retry_delay_ms,
1428 } => {
1429 assert_eq!(state, RealtimeState::Disconnected);
1430 assert_eq!(reason_code, None);
1431 assert_eq!(retry_delay_ms, 250);
1432 }
1433 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1434 }
1435 assert_eq!(transport.sync_calls, 0);
1436 assert!(matches!(
1437 client.drain_sync_intents().as_slice(),
1438 [SyncIntent::Background { delay_ms: 250 }]
1439 ));
1440 let snapshot = client
1441 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1442 .expect("diagnostics");
1443 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1444 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1445 assert_eq!(snapshot.host.realtime_reason_code, None);
1446 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1447 assert_eq!(
1448 snapshot
1449 .last_round
1450 .as_ref()
1451 .and_then(|round| round.error_code.clone()),
1452 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1453 );
1454 }
1455
1456 #[test]
1457 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1458 let mut client = client();
1459 client
1460 .set_realtime_policy(RealtimePolicy::Required)
1461 .expect("set policy");
1462 let mut transport = CountingRealtimeTransport {
1463 refuse_connect: true,
1464 ..Default::default()
1465 };
1466 assert!(client.connect_realtime(&mut transport).is_err());
1467 assert_eq!(client.realtime_state(), RealtimeState::Refused);
1468 let snapshot = client
1469 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1470 .expect("diagnostics");
1471 assert_eq!(
1472 snapshot.host.realtime_reason_code.as_deref(),
1473 Some("transport.failed")
1474 );
1475 match client.sync(&mut transport) {
1476 SyncOutcome::RealtimeUnavailable {
1477 state, reason_code, ..
1478 } => {
1479 assert_eq!(state, RealtimeState::Refused);
1480 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1481 }
1482 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1483 }
1484 assert_eq!(transport.sync_calls, 0);
1485 assert_eq!(transport.realtime_sync_calls, 0);
1486 }
1487
1488 #[test]
1489 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1490 let mut client = client();
1491 client
1492 .set_realtime_policy(RealtimePolicy::Required)
1493 .expect("set policy");
1494 let mut transport = CountingRealtimeTransport::default();
1495 client.connect_realtime(&mut transport).expect("connect");
1496 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1497 assert!(
1498 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1499 "a policy change cannot silently re-route a connected socket"
1500 );
1501 assert!(matches!(
1502 client.sync(&mut transport),
1503 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1504 ));
1505 assert_eq!(client.realtime_state(), RealtimeState::Lost);
1506 assert_eq!(transport.realtime_sync_calls, 1);
1507 assert_eq!(transport.sync_calls, 0);
1508 let snapshot = client
1509 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1510 .expect("diagnostics");
1511 assert_eq!(
1512 snapshot.host.realtime_reason_code.as_deref(),
1513 Some("sync.transport_failed")
1514 );
1515 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1516 match client.sync(&mut transport) {
1517 SyncOutcome::RealtimeUnavailable {
1518 state, reason_code, ..
1519 } => {
1520 assert_eq!(state, RealtimeState::Lost);
1521 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1522 }
1523 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1524 }
1525 assert_eq!(transport.sync_calls, 0);
1526 client.disconnect_realtime(&mut transport);
1527 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1528 assert_eq!(transport.closes, 1);
1529 client.connect_realtime(&mut transport).expect("reconnect");
1530 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1531 assert_eq!(transport.connects, 2);
1532 }
1533
1534 #[test]
1535 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1536 let mut client = client();
1537 client
1538 .set_realtime_policy(RealtimePolicy::Off)
1539 .expect("set policy");
1540 let mut transport = CountingRealtimeTransport::default();
1541 assert!(client.connect_realtime(&mut transport).is_err());
1542 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1543 assert!(matches!(
1544 client.sync(&mut transport),
1545 SyncOutcome::Failed { .. }
1546 ));
1547 assert_eq!(transport.sync_calls, 1);
1548 assert_eq!(transport.realtime_sync_calls, 0);
1549 let snapshot = client
1550 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1551 .expect("diagnostics");
1552 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1553 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1554 }
1555
1556 #[test]
1557 fn optional_policy_keeps_the_http_round() {
1558 let mut client = client();
1559 let mut transport = CountingRealtimeTransport::default();
1560 assert!(matches!(
1561 client.sync(&mut transport),
1562 SyncOutcome::Failed { .. }
1563 ));
1564 assert_eq!(transport.sync_calls, 1);
1565 assert_eq!(transport.realtime_sync_calls, 0);
1566 let snapshot = client
1567 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1568 .expect("diagnostics");
1569 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1570 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1571 }
1572
1573 #[test]
1574 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1575 let mut client = client();
1576 client
1577 .subscribe(
1578 "repair-tasks".to_owned(),
1579 "tasks".to_owned(),
1580 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1581 None,
1582 )
1583 .expect("subscribe");
1584 {
1585 let sub = client
1586 .subs
1587 .iter_mut()
1588 .find(|sub| sub.id == "repair-tasks")
1589 .expect("subscription");
1590 sub.cursor = 42;
1591 sub.synced_once = true;
1592 let persisted = sub.clone();
1593 client.persist_sub(&persisted).unwrap();
1594 }
1595 for table in ["_syncular_base_tasks", "tasks"] {
1596 client
1597 .conn
1598 .execute(
1599 &format!(
1600 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1601 ),
1602 rusqlite::params!["server-row", "p1"],
1603 )
1604 .expect("seed server row");
1605 }
1606 let pending = client
1607 .mutate(vec![Mutation::Upsert {
1608 table: "tasks".to_owned(),
1609 values: Map::from_iter([
1610 ("id".to_owned(), Value::from("offline-row")),
1611 ("project_id".to_owned(), Value::from("p1")),
1612 ]),
1613 base_version: None,
1614 }])
1615 .expect("queue offline work");
1616 client.drain_change_batches();
1617 client.drain_sync_intents();
1618
1619 assert_eq!(
1620 client
1621 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1622 rebootstrap_id: "support-case-001".to_owned(),
1623 })
1624 .expect("rebootstrap"),
1625 LocalDataRebootstrapResult {
1626 already_applied: false,
1627 retained_commits: 1,
1628 reset_subscriptions: 1,
1629 }
1630 );
1631 let visible_ids = client
1632 .conn
1633 .prepare("SELECT id FROM tasks ORDER BY id")
1634 .expect("prepare visible ids")
1635 .query_map([], |row| row.get::<_, String>(0))
1636 .expect("query visible ids")
1637 .collect::<Result<Vec<_>, _>>()
1638 .expect("collect visible ids");
1639 assert_eq!(visible_ids, vec!["offline-row"]);
1640 assert_eq!(client.pending_commit_ids(), vec![pending]);
1641 assert_eq!(
1642 client
1643 .subscription_state("repair-tasks")
1644 .expect("subscription")
1645 .cursor,
1646 -1
1647 );
1648 assert!(client.upgrading());
1649 assert!(client.sync_needed());
1650 assert!(matches!(
1651 client.drain_sync_intents().as_slice(),
1652 [SyncIntent::Interactive]
1653 ));
1654 assert_eq!(client.drain_change_batches().len(), 1);
1655
1656 assert_eq!(
1657 client
1658 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1659 rebootstrap_id: "support-case-001".to_owned(),
1660 })
1661 .expect("idempotent retry"),
1662 LocalDataRebootstrapResult {
1663 already_applied: true,
1664 retained_commits: 1,
1665 reset_subscriptions: 1,
1666 }
1667 );
1668 assert!(client.drain_change_batches().is_empty());
1669 }
1670
1671 #[test]
1672 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1673 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1674 assert_eq!(
1675 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1676 (3, 4)
1677 );
1678 assert_eq!(
1679 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1680 (0, 0)
1681 );
1682 for malformed in [
1683 "",
1684 "{}",
1685 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1686 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1687 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1688 ] {
1689 assert_eq!(
1690 decode_local_rebootstrap_receipt(malformed)
1691 .expect_err("malformed receipt must fail"),
1692 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1693 );
1694 }
1695 }
1696
1697 #[test]
1698 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1699 let path = std::env::temp_dir().join(format!(
1700 "syncular-rebootstrap-receipt-{}.db",
1701 uuid::Uuid::new_v4()
1702 ));
1703 let schema = json!({
1704 "version": 1,
1705 "tables": [{
1706 "name": "tasks",
1707 "primaryKey": "id",
1708 "columns": [
1709 { "name": "id", "type": "string", "nullable": false },
1710 { "name": "project_id", "type": "string", "nullable": false }
1711 ],
1712 "scopes": [{ "pattern": "project:{project_id}" }]
1713 }]
1714 });
1715 let path_string = path.to_str().expect("UTF-8 temp path");
1716
1717 {
1718 let mut first = SyncClient::open_path(
1719 "repair-restart-client".to_owned(),
1720 &schema,
1721 ClientLimits::default(),
1722 path_string,
1723 )
1724 .expect("first open");
1725 first
1726 .subscribe(
1727 "repair-tasks".to_owned(),
1728 "tasks".to_owned(),
1729 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1730 None,
1731 )
1732 .expect("subscribe");
1733 first
1734 .mutate(vec![Mutation::Upsert {
1735 table: "tasks".to_owned(),
1736 values: Map::from_iter([
1737 ("id".to_owned(), Value::from("offline-row")),
1738 ("project_id".to_owned(), Value::from("p1")),
1739 ]),
1740 base_version: None,
1741 }])
1742 .expect("queue offline work");
1743 assert_eq!(
1744 first
1745 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1746 rebootstrap_id: "restart-receipt".to_owned(),
1747 })
1748 .expect("first rebootstrap"),
1749 LocalDataRebootstrapResult {
1750 already_applied: false,
1751 retained_commits: 1,
1752 reset_subscriptions: 1,
1753 }
1754 );
1755 }
1756
1757 let mut reopened = SyncClient::open_path(
1758 "repair-restart-client".to_owned(),
1759 &schema,
1760 ClientLimits::default(),
1761 path_string,
1762 )
1763 .expect("reopen");
1764 reopened.drain_change_batches();
1765 reopened.drain_sync_intents();
1766 assert_eq!(
1767 reopened
1768 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1769 rebootstrap_id: "restart-receipt".to_owned(),
1770 })
1771 .expect("receipt replay"),
1772 LocalDataRebootstrapResult {
1773 already_applied: true,
1774 retained_commits: 1,
1775 reset_subscriptions: 1,
1776 }
1777 );
1778 assert!(reopened.drain_change_batches().is_empty());
1779 assert!(reopened.drain_sync_intents().is_empty());
1780 drop(reopened);
1781 std::fs::remove_file(path).expect("remove temp database");
1782 }
1783
1784 #[test]
1785 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1786 let mut malformed = client();
1787 malformed
1788 .subscribe(
1789 "repair-tasks".to_owned(),
1790 "tasks".to_owned(),
1791 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1792 None,
1793 )
1794 .expect("subscribe");
1795 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1796 malformed.drain_change_batches();
1797 malformed.drain_sync_intents();
1798 let malformed_error = malformed
1799 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1800 rebootstrap_id: "malformed".to_owned(),
1801 })
1802 .expect_err("malformed receipt must fail closed");
1803 assert_eq!(
1804 malformed_error,
1805 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1806 );
1807 assert!(!malformed.upgrading());
1808 assert_eq!(
1809 malformed
1810 .subscription_state("repair-tasks")
1811 .expect("unchanged subscription")
1812 .cursor,
1813 -1
1814 );
1815 assert!(malformed.drain_change_batches().is_empty());
1816 assert!(malformed.drain_sync_intents().is_empty());
1817
1818 let mut unreadable = client();
1819 unreadable
1820 .conn
1821 .execute(
1822 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1823 rusqlite::params!["server-row", "p1"],
1824 )
1825 .expect("seed visible row");
1826 unreadable
1827 .conn
1828 .execute("DROP TABLE _syncular_meta", [])
1829 .expect("break marker storage");
1830 let unreadable_error = unreadable
1831 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1832 rebootstrap_id: "unreadable".to_owned(),
1833 })
1834 .expect_err("unreadable marker storage must fail closed");
1835 assert_eq!(
1836 unreadable_error,
1837 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1838 );
1839 let visible_rows = unreadable
1840 .conn
1841 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1842 .expect("visible projection remains");
1843 assert_eq!(visible_rows, 1);
1844 assert!(!unreadable.upgrading());
1845 assert!(unreadable.drain_change_batches().is_empty());
1846 assert!(unreadable.drain_sync_intents().is_empty());
1847 }
1848
1849 #[test]
1850 fn local_rebootstrap_cannot_bypass_schema_floor() {
1851 let mut client = client();
1852 client.set_schema_floor(Some(SchemaFloor {
1853 required_schema_version: Some(2),
1854 latest_schema_version: Some(2),
1855 }));
1856 let error = client
1857 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1858 rebootstrap_id: "blocked-floor".to_owned(),
1859 })
1860 .expect_err("schema floor must block repair");
1861 assert!(error.contains("cannot bypass an active schema-floor stop"));
1862 }
1863
1864 #[test]
1865 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1866 let mut client = client();
1867 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1868 const COMMIT_COUNT: usize = 32;
1869
1870 for index in 0..COMMIT_COUNT {
1871 client
1872 .mutate(vec![Mutation::Upsert {
1873 table: "tasks".to_owned(),
1874 values: Map::from_iter([
1875 ("id".to_owned(), Value::from(format!("task-{index}"))),
1876 ("project_id".to_owned(), Value::from("project-1")),
1877 ]),
1878 base_version: None,
1879 }])
1880 .expect("queue commit");
1881 }
1882
1883 let (_, request_meta) = client.build_request(false);
1884 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1885 let mut frames = vec![Frame::RespHeader {
1886 required_schema_version: None,
1887 latest_schema_version: None,
1888 log_epoch: Some("epoch-1".to_owned()),
1889 reset_required: Some(false),
1890 }];
1891 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1892 |(index, client_commit_id)| Frame::PushResult {
1893 client_commit_id: client_commit_id.clone(),
1894 status: PushStatus::Applied,
1895 commit_seq: Some(index as i64 + 1),
1896 results: vec![OpResult::Applied { op_index: 0 }],
1897 },
1898 ));
1899 let response = Message {
1900 wire_version: WIRE_VERSION,
1901 msg_kind: MsgKind::Response,
1902 frames,
1903 };
1904 let mut transport =
1905 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1906
1907 client.overlay_rebuild_count.set(0);
1908 client.outcome_prune_count.set(0);
1909 let outcome = client.process_response(&mut transport, response, &request_meta);
1910 assert!(matches!(outcome, SyncOutcome::Ok(_)));
1911 assert!(client.pending_commit_ids().is_empty());
1912 assert_eq!(
1913 client.overlay_rebuild_count.get(),
1914 1,
1915 "one response must reconcile its acknowledged commits with one overlay rebuild"
1916 );
1917 assert_eq!(
1918 client.outcome_prune_count.get(),
1919 1,
1920 "one response must enforce outcome retention once"
1921 );
1922 }
1923
1924 #[test]
1925 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1926 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1927 "columns": [
1928 {"name":"id", "type":"string", "nullable":false},
1929 {"name":"project_id", "type":"string", "nullable":false},
1930 {"name":"title", "type":"string", "nullable":false}
1931 ], "scopes": [{"pattern":"project:{project_id}"}],
1932 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1933 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1934 }] });
1935 let mut incremental =
1936 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1937 let mut reference =
1938 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1939 let tables = vec!["tasks".to_owned()];
1940 for index in 0..32 {
1941 let id = format!("task-{}", index % 8);
1942 let values = Map::from_iter([
1943 ("id".to_owned(), Value::from(id.clone())),
1944 (
1945 "project_id".to_owned(),
1946 Value::from(format!("p{}", index % 2)),
1947 ),
1948 ("title".to_owned(), Value::from(format!("needle{index}"))),
1949 ]);
1950 let payload = encode_row_json(
1951 incremental.schema.table("tasks").unwrap(),
1952 &id,
1953 &values,
1954 &incremental.encryption,
1955 )
1956 .unwrap();
1957 let change = ssp2::model::Change {
1958 table_index: 0,
1959 row_id: id,
1960 op: if index % 5 == 0 {
1961 Op::Delete
1962 } else {
1963 Op::Upsert
1964 },
1965 row_version: Some(index + 1),
1966 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1967 row: Some(payload),
1968 };
1969 reference.overlay_dirty.set(true);
1970 for instance in [&mut incremental, &mut reference] {
1971 instance.begin_observation("mirror_test").unwrap();
1972 let mut batch = ChangeAccumulator::default();
1973 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1974 instance
1975 .apply_commit_changes(&tables, std::slice::from_ref(&change))
1976 .unwrap();
1977 instance.rebuild_overlay_if_dirty();
1978 instance.finish_observation("mirror_test", batch).unwrap();
1979 }
1980 for sql in [
1981 "SELECT * FROM tasks ORDER BY id",
1982 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1983 ] {
1984 assert_eq!(
1985 incremental.query(sql, &[]).unwrap(),
1986 reference.query(sql, &[]).unwrap()
1987 );
1988 }
1989 assert_eq!(incremental.local_revision(), reference.local_revision());
1990 assert_eq!(
1991 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1992 serde_json::to_value(reference.drain_change_batches()).unwrap()
1993 );
1994 }
1995 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1996 assert_eq!(reference.overlay_rebuild_count.get(), 32);
1997 }
1998
1999 #[test]
2000 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
2001 let cases = [
2002 (
2003 "string",
2004 vec![
2005 json!("1"),
2006 json!("01"),
2007 json!(""),
2008 json!("nul\u{0}id"),
2009 json!("é"),
2010 ],
2011 ),
2012 (
2013 "integer",
2014 vec![
2015 json!(i64::MIN),
2016 json!(-1),
2017 json!(0),
2018 json!(1),
2019 json!(i64::MAX),
2020 ],
2021 ),
2022 ("boolean", vec![json!(false), json!(true)]),
2023 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
2024 ];
2025 for (kind, ids) in cases {
2026 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2027 "columns":[{"name":"id","type":kind,"nullable":false},
2028 {"name":"project_id","type":"string","nullable":false}],
2029 "scopes":[{"pattern":"project:{project_id}"}]}]});
2030 let mut client =
2031 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
2032 for (index, id) in ids.iter().enumerate() {
2033 let table = client.schema.table("tasks").unwrap();
2034 client
2035 .write_base_row(
2036 "tasks",
2037 &vec![
2038 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
2039 Some(ColumnValue::String(format!("p{index}"))),
2040 ],
2041 1,
2042 )
2043 .unwrap();
2044 }
2045 client.rebuild_overlay_if_dirty();
2046 for base in [true, false] {
2047 let full = if base {
2048 base_table("tasks")
2049 } else {
2050 visible_table("tasks")
2051 };
2052 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
2053 for row_id in [
2054 "1",
2055 "01",
2056 "1.0",
2057 "1.0000000000000002",
2058 "+1",
2059 "1e0",
2060 " 1",
2061 "-1",
2062 "0",
2063 "9223372036854775807",
2064 "-9223372036854775808",
2065 "true",
2066 "false",
2067 "",
2068 "nul\u{0}id",
2069 "é",
2070 "null",
2071 "{\"a\":1}",
2072 ] {
2073 let old_sql =
2074 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
2075 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
2076 let old = client
2077 .conn
2078 .prepare(&old_sql)
2079 .unwrap()
2080 .query_map([row_id], |row| row.get::<_, String>(0))
2081 .unwrap()
2082 .collect::<Result<Vec<_>, _>>()
2083 .unwrap();
2084 let new = client
2085 .conn
2086 .prepare_cached(&new_sql)
2087 .unwrap()
2088 .query_map([row_id], |row| row.get::<_, String>(0))
2089 .unwrap()
2090 .collect::<Result<Vec<_>, _>>()
2091 .unwrap();
2092 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
2093 let mut batch = ChangeAccumulator::default();
2094 assert_eq!(
2095 client.record_row_scopes(&mut batch, "tasks", row_id, base),
2096 !old.is_empty()
2097 );
2098 let expected = old.first().map(|scope| {
2099 BTreeMap::from([(
2100 "tasks".to_owned(),
2101 Some(BTreeSet::from([format!("project:{scope}")])),
2102 )])
2103 });
2104 assert_eq!(batch.tables, expected.unwrap_or_default());
2105 client
2106 .conn
2107 .execute_batch("SAVEPOINT lookup_delete")
2108 .unwrap();
2109 if base {
2110 client.delete_base_row("tasks", row_id).unwrap();
2111 } else {
2112 client.apply_outbox_ops(&[OutboxOp {
2113 upsert: false,
2114 table: "tasks".into(),
2115 row_id: row_id.into(),
2116 base_version: None,
2117 values: None,
2118 }]);
2119 }
2120 let remaining: i64 = client
2121 .conn
2122 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2123 row.get(0)
2124 })
2125 .unwrap();
2126 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2127 client
2128 .conn
2129 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2130 .unwrap();
2131 client.overlay_dirty.set(false);
2132 }
2133 for (predicate, expected) in [
2134 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2135 (predicate, "SEARCH"),
2136 ] {
2137 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2138 let details = client
2139 .conn
2140 .prepare(&sql)
2141 .unwrap()
2142 .query_map(["1"], |row| row.get::<_, String>(3))
2143 .unwrap()
2144 .collect::<Result<Vec<_>, _>>()
2145 .unwrap();
2146 assert!(
2147 details.iter().any(|detail| detail.contains(expected)),
2148 "{kind}: {details:?}"
2149 );
2150 }
2151 }
2152 }
2153 }
2154
2155 #[test]
2156 fn primary_keys_reject_every_ineligible_column_type() {
2157 for ty in ["float", "bytes", "crdt", "blob_ref"] {
2161 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2162 "columns":[{"name":"id","type":ty,"nullable":false},
2163 {"name":"project_id","type":"string","nullable":false}],
2164 "scopes":[{"pattern":"project:{project_id}"}]}]});
2165 let error =
2166 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2167 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2168 Err(error) => error,
2169 };
2170 assert!(
2171 error.contains("has an ineligible column type (§2.4)"),
2172 "{ty}: {error}"
2173 );
2174 }
2175 for ty in ["string", "integer", "boolean", "json"] {
2176 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2177 "columns":[{"name":"id","type":ty,"nullable":false},
2178 {"name":"project_id","type":"string","nullable":false}],
2179 "scopes":[{"pattern":"project:{project_id}"}]}]});
2180 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2181 .unwrap_or_else(|error| panic!("{ty}: {error}"));
2182 }
2183 }
2184
2185 #[test]
2186 fn pending_row_reconciliation_matches_full_fifo_replay() {
2187 for unique in [false, true] {
2188 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2189 "name": name, "primaryKey": "id", "columns": [
2190 {"name":"id", "type":"string", "nullable":false},
2191 {"name":"project_id", "type":"string", "nullable":false},
2192 {"name":"title", "type":"string", "nullable":false}],
2193 "scopes": [{"pattern":"project:{project_id}"}],
2194 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2195 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2196 })))});
2197 let mut incremental =
2198 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2199 let mut reference =
2200 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2201 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2202 for instance in [&mut incremental, &mut reference] {
2203 for table in &tables {
2204 for index in 0..12 {
2205 instance
2206 .write_base_row(
2207 table,
2208 &vec![
2209 Some(ColumnValue::String(format!("row{index}"))),
2210 Some(ColumnValue::String("p0".into())),
2211 Some(ColumnValue::String(format!("base{index}"))),
2212 ],
2213 1,
2214 )
2215 .unwrap();
2216 }
2217 }
2218 instance.rebuild_overlay_if_dirty();
2219 }
2220 for index in 0..80 {
2221 let mutations = (0..3)
2222 .map(|offset| {
2223 let table = tables[(index + offset) % 2].clone();
2224 let id = format!("row{}", (index + offset) % 16);
2225 if (index + offset) % 7 == 0 {
2226 Mutation::Delete {
2227 table,
2228 row_id: id,
2229 base_version: None,
2230 }
2231 } else {
2232 Mutation::Upsert {
2233 table,
2234 values: Map::from_iter([
2235 ("id".into(), Value::from(id)),
2236 ("project_id".into(), Value::from(format!("p{}", index % 3))),
2237 (
2238 "title".into(),
2239 Value::from(format!("needle{}", (index + offset) % 16)),
2240 ),
2241 ]),
2242 base_version: None,
2243 }
2244 }
2245 })
2246 .collect::<Vec<_>>();
2247 for instance in [&mut incremental, &mut reference] {
2248 instance.mutate(mutations.clone()).unwrap();
2249 }
2250 let changes = (0..3)
2253 .map(|offset| {
2254 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2255 let id = format!("row{}", (index + offset) % 16);
2256 let values = Map::from_iter([
2257 ("id".into(), Value::from(id.clone())),
2258 (
2259 "project_id".into(),
2260 Value::from(format!("p{}", (index + 1) % 3)),
2261 ),
2262 (
2263 "title".into(),
2264 Value::from(format!("remote{index}_{offset}")),
2265 ),
2266 ]);
2267 ssp2::model::Change {
2268 table_index: table_index as u16,
2269 row_id: id.clone(),
2270 op: if (index + offset) % 5 == 0 {
2271 Op::Delete
2272 } else {
2273 Op::Upsert
2274 },
2275 row_version: Some(index as i64 + 2),
2276 scopes: vec![],
2277 row: Some(
2278 encode_row_json(
2279 incremental.schema.table(&tables[table_index]).unwrap(),
2280 &id,
2281 &values,
2282 &incremental.encryption,
2283 )
2284 .unwrap(),
2285 ),
2286 }
2287 })
2288 .collect::<Vec<_>>();
2289 reference.overlay_dirty.set(true);
2290 incremental.apply_commit_frame(&tables, &changes).unwrap();
2291 reference.apply_commit_frame(&tables, &changes).unwrap();
2292 for table in &tables {
2293 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2294 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2295 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2296 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2297 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2298 }
2299 }
2300 assert_eq!(incremental.local_revision(), reference.local_revision());
2301 assert_eq!(
2302 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2303 serde_json::to_value(reference.drain_change_batches()).unwrap()
2304 );
2305 }
2306 assert_eq!(
2307 incremental.overlay_rebuild_count.get(),
2308 if unique { 81 } else { 21 }
2309 );
2310 assert_eq!(reference.overlay_rebuild_count.get(), 81);
2311 }
2312 }
2313
2314 #[test]
2315 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2316 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2317 "columns":[{"name":"id", "type":"string", "nullable":false},
2318 {"name":"title", "type":"string", "nullable":false}],
2319 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2320 let mut client =
2321 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2322 for (id, title) in [("a", "original"), ("b", "occupied")] {
2323 client
2324 .write_base_row(
2325 "tasks",
2326 &vec![
2327 Some(ColumnValue::String(id.into())),
2328 Some(ColumnValue::String(title.into())),
2329 ],
2330 1,
2331 )
2332 .unwrap();
2333 }
2334 client.rebuild_overlay_if_dirty();
2335 let pending = client
2336 .mutate(vec![Mutation::Upsert {
2337 table: "tasks".into(),
2338 values: Map::from_iter([
2339 ("id".into(), Value::from("a")),
2340 ("title".into(), Value::from("available")),
2341 ]),
2342 base_version: None,
2343 }])
2344 .unwrap();
2345 assert_eq!(
2346 client
2347 .conn
2348 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2349 .get::<_, String>(
2350 0
2351 ))
2352 .unwrap(),
2353 "available"
2354 );
2355 for (title, expected) in [("available", "original"), ("occupied", "available")] {
2356 let change = ssp2::model::Change {
2357 table_index: 0,
2358 row_id: "b".into(),
2359 op: Op::Upsert,
2360 row_version: Some(2),
2361 scopes: vec![],
2362 row: Some(
2363 encode_row_json(
2364 client.schema.table("tasks").unwrap(),
2365 "b",
2366 &Map::from_iter([
2367 ("id".into(), Value::from("b")),
2368 ("title".into(), Value::from(title)),
2369 ]),
2370 &client.encryption,
2371 )
2372 .unwrap(),
2373 ),
2374 };
2375 client
2376 .apply_commit_frame(&["tasks".into()], &[change])
2377 .unwrap();
2378 assert_eq!(
2379 client
2380 .conn
2381 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2382 .get::<_, String>(
2383 0
2384 ))
2385 .unwrap(),
2386 expected
2387 );
2388 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2389 }
2390 assert_eq!(client.overlay_rebuild_count.get(), 3);
2391 }
2392
2393 #[test]
2394 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2395 for failure in ["visible", "revision", "commit"] {
2396 let mut client = client();
2397 let id = client
2398 .mutate(vec![Mutation::Upsert {
2399 table: "tasks".into(),
2400 values: Map::from_iter([
2401 ("id".into(), Value::from("t1")),
2402 ("project_id".into(), Value::from("pending")),
2403 ]),
2404 base_version: None,
2405 }])
2406 .unwrap();
2407 client.drain_change_batches();
2408 let revision = client.local_revision();
2409 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2410 let trigger = match failure {
2411 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2412 "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"),
2413 _ => "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(),
2414 };
2415 client.conn.execute_batch(&trigger).unwrap();
2416 let change = ssp2::model::Change {
2417 table_index: 0,
2418 row_id: "t1".into(),
2419 op: Op::Upsert,
2420 row_version: Some(2),
2421 scopes: vec![],
2422 row: Some(
2423 encode_row_json(
2424 client.schema.table("tasks").unwrap(),
2425 "t1",
2426 &Map::from_iter([
2427 ("id".into(), Value::from("t1")),
2428 ("project_id".into(), Value::from("remote")),
2429 ]),
2430 &client.encryption,
2431 )
2432 .unwrap(),
2433 ),
2434 };
2435 assert!(client
2436 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
2437 .is_err());
2438 assert!(client.conn.is_autocommit());
2439 assert!(!client.overlay_dirty.get());
2440 assert_eq!(client.local_revision(), revision);
2441 assert!(client.drain_change_batches().is_empty());
2442 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2443 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2444 assert!(client
2445 .query("SELECT * FROM _syncular_base_tasks", &[])
2446 .unwrap()
2447 .is_empty());
2448 client
2449 .conn
2450 .execute_batch("DROP TRIGGER fail_pending")
2451 .unwrap();
2452 client
2453 .apply_commit_frame(&["tasks".into()], &[change])
2454 .unwrap();
2455 assert_eq!(client.local_revision(), revision + 1);
2456 assert_eq!(client.pending_commit_ids(), vec![id]);
2457 let mut expected = before[0].clone();
2460 expected.insert("_syncular_version".to_owned(), Value::from(2));
2461 assert_eq!(
2462 client.query("SELECT * FROM tasks", &[]).unwrap(),
2463 vec![expected]
2464 );
2465 assert_eq!(
2466 client
2467 .conn
2468 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2469 .get::<_, String>(0))
2470 .unwrap(),
2471 "remote"
2472 );
2473 }
2474 }
2475
2476 #[test]
2477 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2478 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2479 "columns": [
2480 {"name":"id", "type":"string", "nullable":false},
2481 {"name":"project_id", "type":"string", "nullable":false},
2482 {"name":"title", "type":"string", "nullable":false}],
2483 "scopes": [{"pattern":"project:{project_id}"}],
2484 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2485 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2486 let mut incremental =
2487 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2488 let mut reference =
2489 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2490 for instance in [&mut incremental, &mut reference] {
2491 for index in 0..16 {
2492 instance
2493 .write_base_row(
2494 "tasks",
2495 &vec![
2496 Some(ColumnValue::String(format!("task{index}"))),
2497 Some(ColumnValue::String("p0".to_owned())),
2498 Some(ColumnValue::String(format!("base{index}"))),
2499 ],
2500 1,
2501 )
2502 .unwrap();
2503 }
2504 instance.rebuild_overlay_if_dirty();
2505 instance.overlay_rebuild_count.set(0);
2506 }
2507 for index in 0..100 {
2508 let mutations = (0..1 + index % 4)
2509 .map(|offset| {
2510 let id = format!("task{}", (index + offset) % 24);
2511 if (index + offset) % 7 == 0 {
2512 Mutation::Delete {
2513 table: "tasks".to_owned(),
2514 row_id: id,
2515 base_version: None,
2516 }
2517 } else {
2518 Mutation::Upsert {
2519 table: "tasks".to_owned(),
2520 values: Map::from_iter([
2521 ("id".to_owned(), Value::from(id)),
2522 (
2523 "project_id".to_owned(),
2524 Value::from(format!("p{}", index % 2)),
2525 ),
2526 (
2527 "title".to_owned(),
2528 Value::from(format!("needle{}", (index + offset) % 24)),
2529 ),
2530 ]),
2531 base_version: None,
2532 }
2533 }
2534 })
2535 .collect::<Vec<_>>();
2536 reference.overlay_dirty.set(true);
2537 incremental.mutate(mutations.clone()).unwrap();
2538 reference.mutate(mutations).unwrap();
2539 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2540 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2541 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2542 }
2543 assert_eq!(incremental.local_revision(), reference.local_revision());
2544 assert_eq!(
2545 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2546 serde_json::to_value(reference.drain_change_batches()).unwrap()
2547 );
2548 }
2549 assert_eq!(incremental.outbox.len(), 100);
2550 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2551 assert_eq!(reference.overlay_rebuild_count.get(), 100);
2552 }
2553
2554 #[test]
2555 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2556 for fail_at in ["outbox", "revision"] {
2557 let mut client = client();
2558 let values = Map::from_iter([
2559 ("id".to_owned(), Value::from("t1")),
2560 ("project_id".to_owned(), Value::from("p1")),
2561 ]);
2562 let mutation = Mutation::Upsert {
2563 table: "tasks".to_owned(),
2564 values,
2565 base_version: None,
2566 };
2567 let trigger = if fail_at == "outbox" {
2568 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2569 } else {
2570 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2571 };
2572 client.conn.execute_batch(&trigger).unwrap();
2573 let revision = client.local_revision();
2574 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2575 assert_eq!(client.local_revision(), revision);
2576 assert!(client.outbox.is_empty());
2577 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2578 assert_eq!(
2579 client
2580 .conn
2581 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2582 .get::<_, i64>(0))
2583 .unwrap(),
2584 0
2585 );
2586 assert!(client.drain_change_batches().is_empty());
2587 assert!(!client.overlay_dirty.get());
2588 client
2589 .conn
2590 .execute_batch("DROP TRIGGER fail_append")
2591 .unwrap();
2592 client.mutate(vec![mutation]).unwrap();
2593 assert_eq!(client.outbox.len(), 1);
2594 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2595 }
2596 }
2597
2598 #[test]
2599 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2600 let mut client = client();
2601 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2602 let values = Map::from_iter([
2603 ("id".to_owned(), Value::from("t1")),
2604 ("project_id".to_owned(), Value::from("p1")),
2605 ]);
2606 let payload = encode_row_json(
2607 client.schema.table("tasks").unwrap(),
2608 "t1",
2609 &values,
2610 &client.encryption,
2611 )
2612 .unwrap();
2613 let first = ssp2::model::Change {
2614 table_index: 0,
2615 row_id: "t1".to_owned(),
2616 op: Op::Upsert,
2617 row_version: Some(1),
2618 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2619 row: Some(payload),
2620 };
2621 let mut invalid = first.clone();
2622 invalid.table_index = 1;
2623 let revision = client.local_revision();
2624 client.begin_observation("failed_mirror").unwrap();
2625 assert!(client
2626 .apply_commit_changes(&tables, &[first, invalid])
2627 .is_err());
2628 client.rollback_observation("failed_mirror");
2629 client.rebuild_overlay_if_dirty();
2630 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2631 assert_eq!(
2632 client
2633 .conn
2634 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2635 .get::<_, i64>(
2636 0
2637 ))
2638 .unwrap(),
2639 0
2640 );
2641 assert_eq!(client.local_revision(), revision);
2642 assert!(client.drain_change_batches().is_empty());
2643 }
2644
2645 #[test]
2646 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2647 for failure in ["journal", "revision", "commit", "later-frame"] {
2648 let mut client = client();
2649 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2650 for id in ["first", "second"] {
2651 client
2652 .mutate(vec![Mutation::Upsert {
2653 table: "tasks".to_owned(),
2654 values: Map::from_iter([
2655 ("id".to_owned(), Value::from(id)),
2656 ("project_id".to_owned(), Value::from("p1")),
2657 ]),
2658 base_version: None,
2659 }])
2660 .unwrap();
2661 }
2662 let (_, meta) = client.build_request(false);
2663 client
2665 .mutate(vec![Mutation::Upsert {
2666 table: "tasks".to_owned(),
2667 values: Map::from_iter([
2668 ("id".to_owned(), Value::from("later")),
2669 ("project_id".to_owned(), Value::from("p1")),
2670 ]),
2671 base_version: None,
2672 }])
2673 .unwrap();
2674 let ids = client.pending_commit_ids();
2675 let rows = client
2676 .query("SELECT * FROM tasks ORDER BY id", &[])
2677 .unwrap();
2678 let revision = client.local_revision();
2679 client.drain_change_batches();
2680 if failure != "later-frame" {
2681 client.conn.execute_batch(match failure {
2682 "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",
2683 "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",
2684 _ => "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",
2685 }).unwrap();
2686 }
2687 let mut response = Message {
2688 wire_version: WIRE_VERSION,
2689 msg_kind: MsgKind::Response,
2690 frames: vec![Frame::RespHeader {
2691 required_schema_version: None,
2692 latest_schema_version: None,
2693 log_epoch: Some("epoch-1".to_owned()),
2694 reset_required: Some(false),
2695 }],
2696 };
2697 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2698 response.frames.push(Frame::PushResult {
2699 client_commit_id: id.to_owned(),
2700 status: PushStatus::Applied,
2701 commit_seq: Some(1),
2702 results: vec![OpResult::Applied { op_index: 0 }],
2703 });
2704 }
2705 if failure == "later-frame" {
2706 response.frames.push(Frame::Error {
2707 code: "sync.invalid_request".to_owned(),
2708 message: "later error".to_owned(),
2709 category: "protocol".to_owned(),
2710 retryable: false,
2711 recommended_action: "retry".to_owned(),
2712 details: None,
2713 });
2714 }
2715 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2716 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2717 assert!(
2718 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2719 );
2720 if failure == "later-frame" {
2721 assert_eq!(client.pending_commit_ids(), ids[2..]);
2722 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2723 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2724 let batches = client.drain_change_batches();
2725 assert_eq!(batches.len(), 1);
2726 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2727 assert_eq!(client.local_revision(), revision + 1);
2728 response.frames.pop();
2729 } else {
2730 assert_eq!(client.pending_commit_ids(), ids);
2731 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2732 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2733 assert_eq!(client.local_revision(), revision);
2734 assert!(client.drain_change_batches().is_empty());
2735 assert_eq!(
2736 client
2737 .query("SELECT * FROM tasks ORDER BY id", &[])
2738 .unwrap(),
2739 rows
2740 );
2741 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2742 }
2743 for frame in &mut response.frames {
2744 if let Frame::PushResult { status, .. } = frame {
2745 *status = PushStatus::Cached;
2746 }
2747 }
2748 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2749 else {
2750 panic!("retry failed");
2751 };
2752 assert_eq!(
2753 report.applied,
2754 if failure == "later-frame" {
2755 vec![]
2756 } else {
2757 ids[..2].to_vec()
2758 }
2759 );
2760 assert_eq!(client.pending_commit_ids(), ids[2..]);
2761 let batches = client.drain_change_batches();
2762 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2763 if let Some(batch) = batches.first() {
2764 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2765 }
2766 assert_eq!(client.local_revision(), revision + 1);
2767 }
2768 }
2769
2770 #[test]
2771 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2772 for failure in ["revision", "journal", "commit"] {
2773 for status in ["applied", "cached", "rejected", "conflict"] {
2774 let mut client = client();
2775 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2776 for id in ["first", "later"] {
2777 client
2778 .mutate(vec![Mutation::Upsert {
2779 table: "tasks".to_owned(),
2780 values: Map::from_iter([
2781 ("id".to_owned(), Value::from(id)),
2782 ("project_id".to_owned(), Value::from("p1")),
2783 ]),
2784 base_version: None,
2785 }])
2786 .unwrap();
2787 }
2788 let (_, meta) = client.build_request(false);
2789 let ids = client.pending_commit_ids();
2790 let rows = client
2791 .query("SELECT * FROM tasks ORDER BY id", &[])
2792 .unwrap();
2793 let revision = client.local_revision();
2794 client.drain_change_batches();
2795 let (push_status, results) = match status {
2796 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2797 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2798 "conflict" => (
2799 PushStatus::Rejected,
2800 vec![OpResult::Conflict {
2801 op_index: 0,
2802 code: "sync.version_conflict".to_owned(),
2803 message: "conflict".to_owned(),
2804 server_version: 1,
2805 server_row: encode_row_json(
2806 client.schema.table("tasks").unwrap(),
2807 "first",
2808 &Map::from_iter([
2809 ("id".to_owned(), Value::from("first")),
2810 ("project_id".to_owned(), Value::from("p1")),
2811 ]),
2812 &client.encryption,
2813 )
2814 .unwrap(),
2815 conflict_columns: Vec::new(),
2816 }],
2817 ),
2818 _ => (
2819 PushStatus::Rejected,
2820 vec![OpResult::Error {
2821 op_index: 0,
2822 code: "sync.validation_failed".to_owned(),
2823 message: "rejected".to_owned(),
2824 retryable: false,
2825 }],
2826 ),
2827 };
2828 let response = Message {
2829 wire_version: WIRE_VERSION,
2830 msg_kind: MsgKind::Response,
2831 frames: vec![
2832 Frame::RespHeader {
2833 required_schema_version: None,
2834 latest_schema_version: None,
2835 log_epoch: Some("epoch-1".to_owned()),
2836 reset_required: Some(false),
2837 },
2838 Frame::PushResult {
2839 client_commit_id: ids[0].clone(),
2840 status: push_status,
2841 commit_seq: Some(1),
2842 results,
2843 },
2844 ],
2845 };
2846 client.conn.execute_batch(match failure {
2847 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2848 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2849 _ => "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",
2850 }).unwrap();
2851 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2852 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2853 assert_eq!(
2854 client.pending_commit_ids(),
2855 ids,
2856 "{failure}/{status}: pending identity"
2857 );
2858 assert_eq!(
2859 client.local_revision(),
2860 revision,
2861 "{failure}/{status}: revision"
2862 );
2863 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2864 assert!(client.conflicts().is_empty());
2865 assert!(client.rejections().is_empty());
2866 assert!(client.drain_change_batches().is_empty());
2867 assert_eq!(
2868 client
2869 .query("SELECT * FROM tasks ORDER BY id", &[])
2870 .unwrap(),
2871 rows
2872 );
2873 assert!(
2874 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2875 "{failure}/{status}: failed round"
2876 );
2877 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2878 assert!(matches!(
2879 client.process_response(&mut transport, response, &meta),
2880 SyncOutcome::Ok(_)
2881 ));
2882 assert_eq!(client.pending_commit_ids(), ids[1..]);
2883 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2884 assert_eq!(client.drain_change_batches().len(), 1);
2885 }
2886 }
2887 }
2888
2889 #[test]
2890 fn mixed_push_results_reconcile_and_prune_once_per_response() {
2891 let mut client = client();
2892 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2893 for index in 0..4 {
2894 client
2895 .mutate(vec![Mutation::Upsert {
2896 table: "tasks".to_owned(),
2897 values: Map::from_iter([
2898 ("id".to_owned(), Value::from(format!("task-{index}"))),
2899 ("project_id".to_owned(), Value::from("project-1")),
2900 ]),
2901 base_version: None,
2902 }])
2903 .expect("queue commit");
2904 }
2905
2906 let (_, request_meta) = client.build_request(false);
2907 let ids = &request_meta.pushed_ids;
2908 assert_eq!(ids.len(), 4);
2909 let response = Message {
2910 wire_version: WIRE_VERSION,
2911 msg_kind: MsgKind::Response,
2912 frames: vec![
2913 Frame::RespHeader {
2914 required_schema_version: None,
2915 latest_schema_version: None,
2916 log_epoch: Some("epoch-1".to_owned()),
2917 reset_required: Some(false),
2918 },
2919 Frame::PushResult {
2920 client_commit_id: ids[0].clone(),
2921 status: PushStatus::Applied,
2922 commit_seq: Some(1),
2923 results: vec![OpResult::Applied { op_index: 0 }],
2924 },
2925 Frame::PushResult {
2926 client_commit_id: ids[1].clone(),
2927 status: PushStatus::Cached,
2928 commit_seq: Some(2),
2929 results: vec![OpResult::Applied { op_index: 0 }],
2930 },
2931 Frame::PushResult {
2932 client_commit_id: ids[2].clone(),
2933 status: PushStatus::Rejected,
2934 commit_seq: None,
2935 results: vec![OpResult::Error {
2936 op_index: 0,
2937 code: "sync.validation_failed".to_owned(),
2938 message: "rejected".to_owned(),
2939 retryable: false,
2940 }],
2941 },
2942 Frame::PushResult {
2943 client_commit_id: ids[3].clone(),
2944 status: PushStatus::Rejected,
2945 commit_seq: None,
2946 results: vec![OpResult::Error {
2947 op_index: 0,
2948 code: "sync.idempotency_cache_miss".to_owned(),
2949 message: "retry".to_owned(),
2950 retryable: true,
2951 }],
2952 },
2953 ],
2954 };
2955 let mut transport =
2956 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2957
2958 client.overlay_rebuild_count.set(0);
2959 client.outcome_prune_count.set(0);
2960 let outcome = client.process_response(&mut transport, response, &request_meta);
2961 let SyncOutcome::Ok(report) = outcome else {
2962 panic!("mixed push-result response failed");
2963 };
2964
2965 assert_eq!(report.applied, ids[..2]);
2966 assert_eq!(report.rejected, ids[2..3]);
2967 assert_eq!(report.retryable, ids[3..4]);
2968 assert_eq!(client.pending_commit_ids(), ids[3..4]);
2969 assert_eq!(
2970 client
2971 .query("SELECT id FROM tasks ORDER BY id", &[])
2972 .expect("query visible overlay"),
2973 vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2974 );
2975 assert_eq!(client.overlay_rebuild_count.get(), 1);
2976 assert_eq!(client.outcome_prune_count.get(), 1);
2977 }
2978
2979 #[test]
2980 fn secondary_unique_collision_preserves_existing_synced_row() {
2981 let client = SyncClient::new(
2982 "unique-upsert-test".to_owned(),
2983 &json!({
2984 "version": 1,
2985 "tables": [{
2986 "name": "tasks",
2987 "primaryKey": "id",
2988 "columns": [
2989 { "name": "id", "type": "string", "nullable": false },
2990 { "name": "project_id", "type": "string", "nullable": false },
2991 { "name": "title", "type": "string", "nullable": false }
2992 ],
2993 "scopes": [{ "pattern": "project:{project_id}" }],
2994 "indexes": [{
2995 "name": "tasks_by_project_title",
2996 "columns": ["project_id", "title"],
2997 "unique": true
2998 }]
2999 }]
3000 }),
3001 ClientLimits::default(),
3002 )
3003 .expect("test client");
3004 let table = client.schema.table("tasks").expect("tasks table");
3005 let sql = client.insert_row_sql(&base_table("tasks"), table);
3006
3007 client
3008 .conn
3009 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
3010 .expect("insert first row");
3011 client
3012 .conn
3013 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
3014 .expect("update same primary key");
3015 client
3016 .conn
3017 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
3018 .expect("insert second row");
3019 assert!(client
3020 .conn
3021 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
3022 .is_err());
3023
3024 let rows = client
3025 .conn
3026 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
3027 .expect("prepare rows")
3028 .query_map([], |row| {
3029 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3030 })
3031 .expect("query rows")
3032 .collect::<Result<Vec<_>, _>>()
3033 .expect("collect rows");
3034 assert_eq!(
3035 rows,
3036 vec![
3037 ("t1".to_owned(), "updated".to_owned()),
3038 ("t2".to_owned(), "original".to_owned())
3039 ]
3040 );
3041 }
3042
3043 #[test]
3044 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
3045 let path = std::env::temp_dir().join(format!(
3046 "syncular-startup-intent-{}.db",
3047 uuid::Uuid::new_v4()
3048 ));
3049 let schema = json!({
3050 "version": 1,
3051 "tables": [{
3052 "name": "tasks",
3053 "primaryKey": "id",
3054 "columns": [
3055 { "name": "id", "type": "string", "nullable": false },
3056 { "name": "project_id", "type": "string", "nullable": false }
3057 ],
3058 "scopes": [{ "pattern": "project:{project_id}" }]
3059 }]
3060 });
3061
3062 {
3063 let mut first = SyncClient::open_path_with_identity(
3064 None,
3065 &schema,
3066 ClientLimits::default(),
3067 path.to_str().expect("UTF-8 temp path"),
3068 )
3069 .expect("first open");
3070 first
3071 .set_window(
3072 &WindowBase {
3073 table: "tasks".to_owned(),
3074 variable: "project_id".to_owned(),
3075 fixed_scopes: Vec::new(),
3076 params: None,
3077 },
3078 &["persisted".to_owned()],
3079 )
3080 .expect("persist window");
3081 }
3082
3083 let mut reopened = SyncClient::open_path_with_identity(
3084 None,
3085 &schema,
3086 ClientLimits::default(),
3087 path.to_str().expect("UTF-8 temp path"),
3088 )
3089 .expect("reopen");
3090 assert!(reopened.sync_needed());
3091 assert!(matches!(
3092 reopened.drain_sync_intents().as_slice(),
3093 [SyncIntent::Interactive]
3094 ));
3095 drop(reopened);
3096 std::fs::remove_file(path).expect("remove temp database");
3097 }
3098
3099 #[test]
3100 fn reopening_preserves_immutable_subscription_identity_and_progress() {
3101 let path = std::env::temp_dir().join(format!(
3102 "syncular-subscription-identity-{}.db",
3103 uuid::Uuid::new_v4()
3104 ));
3105 let schema = json!({
3106 "version": 1,
3107 "tables": [
3108 {
3109 "name": "tasks",
3110 "primaryKey": "id",
3111 "columns": [
3112 { "name": "id", "type": "string", "nullable": false },
3113 { "name": "project_id", "type": "string", "nullable": false }
3114 ],
3115 "scopes": [{ "pattern": "project:{project_id}" }]
3116 },
3117 {
3118 "name": "docs",
3119 "primaryKey": "id",
3120 "columns": [
3121 { "name": "id", "type": "string", "nullable": false },
3122 { "name": "org_id", "type": "string", "nullable": false },
3123 { "name": "project_id", "type": "string", "nullable": false }
3124 ],
3125 "scopes": [
3126 { "pattern": "org:{org_id}" },
3127 { "pattern": "project:{projectId}", "column": "project_id" }
3128 ]
3129 }
3130 ]
3131 });
3132
3133 {
3134 let mut first = SyncClient::open_path_with_identity(
3135 None,
3136 &schema,
3137 ClientLimits::default(),
3138 path.to_str().expect("UTF-8 temp path"),
3139 )
3140 .expect("first open");
3141 first
3142 .subscribe(
3143 "stable-subscription".to_owned(),
3144 "tasks".to_owned(),
3145 vec![(
3146 "project_id".to_owned(),
3147 vec!["p2".to_owned(), "p1".to_owned()],
3148 )],
3149 Some(r#"{"view":"v1"}"#.to_owned()),
3150 )
3151 .expect("persist subscription");
3152 let persisted = {
3153 let subscription = first
3154 .subs
3155 .iter_mut()
3156 .find(|subscription| subscription.id == "stable-subscription")
3157 .expect("subscription");
3158 subscription.cursor = 41;
3159 subscription.bootstrap_state = Some("resume-token".to_owned());
3160 subscription.effective = Some(vec![(
3161 "project_id".to_owned(),
3162 vec!["p1".to_owned(), "p2".to_owned()],
3163 )]);
3164 subscription.synced_once = true;
3165 subscription.clone()
3166 };
3167 first.persist_sub(&persisted).unwrap();
3168 }
3169
3170 let mut reopened = SyncClient::open_path_with_identity(
3171 None,
3172 &schema,
3173 ClientLimits::default(),
3174 path.to_str().expect("UTF-8 temp path"),
3175 )
3176 .expect("reopen");
3177 let progress = reopened
3178 .subscription_state("stable-subscription")
3179 .expect("persisted state");
3180 assert_eq!(progress.cursor, 41);
3181 assert!(progress.has_resume_token);
3182
3183 reopened
3184 .subscribe(
3185 "stable-subscription".to_owned(),
3186 "tasks".to_owned(),
3187 vec![(
3188 "project_id".to_owned(),
3189 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3190 )],
3191 Some(r#"{"view":"v1"}"#.to_owned()),
3192 )
3193 .expect("canonical intent is idempotent");
3194 assert_eq!(
3195 reopened
3196 .subscription_state("stable-subscription")
3197 .expect("unchanged state")
3198 .cursor,
3199 progress.cursor
3200 );
3201
3202 for (table, scopes, params) in [
3203 (
3204 "tasks",
3205 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3206 Some(r#"{"view":"v1"}"#.to_owned()),
3207 ),
3208 (
3209 "tasks",
3210 vec![(
3211 "project_id".to_owned(),
3212 vec!["p2".to_owned(), "p1".to_owned()],
3213 )],
3214 Some(r#"{"view":"v2"}"#.to_owned()),
3215 ),
3216 (
3217 "docs",
3218 vec![
3219 ("org_id".to_owned(), vec!["o1".to_owned()]),
3220 ("projectId".to_owned(), vec!["p1".to_owned()]),
3221 ],
3222 Some(r#"{"view":"v1"}"#.to_owned()),
3223 ),
3224 ] {
3225 let error = reopened
3226 .subscribe(
3227 "stable-subscription".to_owned(),
3228 table.to_owned(),
3229 scopes,
3230 params,
3231 )
3232 .expect_err("identity rebind must fail");
3233 assert!(error.starts_with("client.subscription_intent_mismatch:"));
3234 assert_eq!(
3235 reopened
3236 .subscription_state("stable-subscription")
3237 .expect("unchanged state")
3238 .cursor,
3239 progress.cursor
3240 );
3241 }
3242
3243 drop(reopened);
3244 std::fs::remove_file(path).expect("remove temp database");
3245 }
3246
3247 #[test]
3248 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
3249 let path = std::env::temp_dir().join(format!(
3250 "syncular-satisfied-schema-floor-{}.db",
3251 uuid::Uuid::new_v4()
3252 ));
3253 let schema = json!({
3254 "version": 23,
3255 "tables": [{
3256 "name": "tasks",
3257 "primaryKey": "id",
3258 "columns": [
3259 { "name": "id", "type": "string", "nullable": false },
3260 { "name": "project_id", "type": "string", "nullable": false }
3261 ],
3262 "scopes": [{ "pattern": "project:{project_id}" }]
3263 }]
3264 });
3265
3266 {
3267 let mut first = SyncClient::open_path_with_identity(
3268 None,
3269 &schema,
3270 ClientLimits::default(),
3271 path.to_str().expect("UTF-8 temp path"),
3272 )
3273 .expect("first open");
3274 first
3275 .subscribe(
3276 "tasks".to_owned(),
3277 "tasks".to_owned(),
3278 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3279 None,
3280 )
3281 .expect("persist subscription");
3282 first.set_schema_floor(Some(SchemaFloor {
3283 required_schema_version: Some(22),
3284 latest_schema_version: Some(22),
3285 }));
3286 }
3287
3288 let mut reopened = SyncClient::open_path_with_identity(
3289 None,
3290 &schema,
3291 ClientLimits::default(),
3292 path.to_str().expect("UTF-8 temp path"),
3293 )
3294 .expect("reopen");
3295 assert!(reopened.schema_floor().is_none());
3296 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3297 assert!(reopened.sync_needed());
3298 assert!(matches!(
3299 reopened.drain_sync_intents().as_slice(),
3300 [SyncIntent::Interactive]
3301 ));
3302 drop(reopened);
3303 std::fs::remove_file(path).expect("remove temp database");
3304 }
3305
3306 #[test]
3307 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3308 let path = std::env::temp_dir().join(format!(
3309 "syncular-unsatisfied-schema-floor-{}.db",
3310 uuid::Uuid::new_v4()
3311 ));
3312 let schema = json!({
3313 "version": 1,
3314 "tables": [{
3315 "name": "tasks",
3316 "primaryKey": "id",
3317 "columns": [
3318 { "name": "id", "type": "string", "nullable": false },
3319 { "name": "project_id", "type": "string", "nullable": false }
3320 ],
3321 "scopes": [{ "pattern": "project:{project_id}" }]
3322 }]
3323 });
3324
3325 {
3326 let mut first = SyncClient::open_path_with_identity(
3327 None,
3328 &schema,
3329 ClientLimits::default(),
3330 path.to_str().expect("UTF-8 temp path"),
3331 )
3332 .expect("first open");
3333 first
3334 .subscribe(
3335 "tasks".to_owned(),
3336 "tasks".to_owned(),
3337 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3338 None,
3339 )
3340 .expect("persist subscription");
3341 first.set_schema_floor(Some(SchemaFloor {
3342 required_schema_version: Some(2),
3343 latest_schema_version: Some(2),
3344 }));
3345 }
3346
3347 let reopened = SyncClient::open_path_with_identity(
3348 None,
3349 &schema,
3350 ClientLimits::default(),
3351 path.to_str().expect("UTF-8 temp path"),
3352 )
3353 .expect("reopen");
3354 assert_eq!(
3355 reopened.schema_floor(),
3356 Some(&SchemaFloor {
3357 required_schema_version: Some(2),
3358 latest_schema_version: Some(2),
3359 })
3360 );
3361 assert!(!reopened.sync_needed());
3362 drop(reopened);
3363 std::fs::remove_file(path).expect("remove temp database");
3364 }
3365
3366 #[test]
3367 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3368 let path = std::env::temp_dir().join(format!(
3369 "syncular-indexed-column-bump-{}.db",
3370 uuid::Uuid::new_v4()
3371 ));
3372 let old_schema = json!({
3373 "version": 1,
3374 "tables": [
3375 {
3376 "name": "tasks",
3377 "primaryKey": "id",
3378 "columns": [
3379 { "name": "id", "type": "string", "nullable": false },
3380 { "name": "project_id", "type": "string", "nullable": false }
3381 ],
3382 "scopes": [{ "pattern": "project:{project_id}" }]
3383 },
3384 {
3385 "name": "legacy",
3386 "primaryKey": "id",
3387 "columns": [
3388 { "name": "id", "type": "string", "nullable": false },
3389 { "name": "project_id", "type": "string", "nullable": false }
3390 ],
3391 "scopes": [{ "pattern": "project:{project_id}" }]
3392 }
3393 ]
3394 });
3395 {
3396 let mut first = SyncClient::open_path_with_identity(
3397 None,
3398 &old_schema,
3399 ClientLimits::default(),
3400 path.to_str().expect("UTF-8 temp path"),
3401 )
3402 .expect("open old schema");
3403 first
3404 .subscribe(
3405 "legacy-sub".to_owned(),
3406 "legacy".to_owned(),
3407 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3408 None,
3409 )
3410 .expect("persist legacy subscription");
3411 }
3412
3413 let new_schema = json!({
3414 "version": 2,
3415 "tables": [{
3416 "name": "tasks",
3417 "primaryKey": "id",
3418 "columns": [
3419 { "name": "id", "type": "string", "nullable": false },
3420 { "name": "project_id", "type": "string", "nullable": false },
3421 { "name": "facility_membership_id", "type": "string", "nullable": true }
3422 ],
3423 "scopes": [{ "pattern": "project:{project_id}" }],
3424 "indexes": [{
3425 "name": "tasks_by_membership",
3426 "columns": ["project_id", "facility_membership_id"],
3427 "unique": false
3428 }]
3429 }]
3430 });
3431 let upgraded = SyncClient::open_path_with_identity(
3432 None,
3433 &new_schema,
3434 ClientLimits::default(),
3435 path.to_str().expect("UTF-8 temp path"),
3436 )
3437 .expect("open upgraded schema");
3438 let columns = upgraded
3439 .conn
3440 .prepare("PRAGMA table_info(tasks)")
3441 .expect("prepare columns")
3442 .query_map([], |row| row.get::<_, String>(1))
3443 .expect("query columns")
3444 .collect::<Result<Vec<_>, _>>()
3445 .expect("collect columns");
3446 assert!(columns.contains(&"facility_membership_id".to_owned()));
3447 let index_count: i64 = upgraded
3448 .conn
3449 .query_row(
3450 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3451 [],
3452 |row| row.get(0),
3453 )
3454 .expect("query index");
3455 assert_eq!(index_count, 1);
3456 assert!(upgraded.subscription_state("legacy-sub").is_none());
3457 drop(upgraded);
3458 std::fs::remove_file(path).expect("remove temp database");
3459 }
3460
3461 #[test]
3462 fn migrates_pre_envelope_outcome_journal_additively() {
3463 let path = std::env::temp_dir().join(format!(
3464 "syncular-outcome-migration-{}.db",
3465 uuid::Uuid::new_v4()
3466 ));
3467 let conn = Connection::open(&path).expect("open legacy database");
3468 conn.execute_batch(
3469 "CREATE TABLE _syncular_commit_outcomes (
3470 seq INTEGER PRIMARY KEY AUTOINCREMENT,
3471 client_commit_id TEXT NOT NULL UNIQUE,
3472 status TEXT NOT NULL,
3473 recorded_at_ms INTEGER NOT NULL,
3474 results_json TEXT NOT NULL,
3475 resolution TEXT NOT NULL DEFAULT 'active',
3476 resolved_at_ms INTEGER,
3477 replacement_client_commit_id TEXT);",
3478 )
3479 .expect("create legacy outcome journal");
3480 drop(conn);
3481 let schema = json!({
3482 "version": 1,
3483 "tables": [{
3484 "name": "tasks",
3485 "primaryKey": "id",
3486 "columns": [
3487 { "name": "id", "type": "string", "nullable": false },
3488 { "name": "project_id", "type": "string", "nullable": false }
3489 ],
3490 "scopes": [{ "pattern": "project:{project_id}" }]
3491 }]
3492 });
3493 let client = SyncClient::open_path(
3494 "migration-native".to_owned(),
3495 &schema,
3496 ClientLimits::default(),
3497 path.to_str().expect("UTF-8 temp path"),
3498 )
3499 .expect("migrate database");
3500 let has_operations = client
3501 .conn
3502 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3503 .expect("prepare table info")
3504 .query_map([], |row| row.get::<_, String>(1))
3505 .expect("query table info")
3506 .filter_map(Result::ok)
3507 .any(|column| column == "operations_json");
3508 assert!(has_operations);
3509 drop(client);
3510 std::fs::remove_file(path).expect("remove temp database");
3511 }
3512
3513 #[test]
3514 fn retained_rejection_survives_file_reopen_and_security_purge() {
3515 let path =
3516 std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
3517 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
3518 { "name": "id", "type": "string", "nullable": false },
3519 { "name": "project_id", "type": "string", "nullable": false },
3520 { "name": "title", "type": "string", "nullable": false }
3521 ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
3522 {
3523 let mut first = SyncClient::open_path(
3524 "retained-native".to_owned(),
3525 &schema,
3526 ClientLimits::default(),
3527 path.to_str().unwrap(),
3528 )
3529 .unwrap();
3530 first
3531 .write_base_row(
3532 "tasks",
3533 &vec![
3534 Some(ColumnValue::String("t1".into())),
3535 Some(ColumnValue::String("p1".into())),
3536 Some(ColumnValue::String("server".into())),
3537 ],
3538 1,
3539 )
3540 .unwrap();
3541 first.rebuild_overlay();
3542 let operation = OutboxOp {
3543 table: "tasks".into(),
3544 row_id: "t1".into(),
3545 upsert: true,
3546 base_version: Some(1),
3547 values: Some(Map::from_iter([
3548 ("id".into(), json!("t1")),
3549 ("title".into(), json!("mine")),
3550 ])),
3551 };
3552 first
3553 .retain_failed_commit("retained", std::slice::from_ref(&operation))
3554 .unwrap();
3555 first
3556 .persist_commit_outcome(
3557 "retained",
3558 CommitOutcomeStatus::Rejected,
3559 &[],
3560 Some(&[operation]),
3561 )
3562 .unwrap();
3563 first.rebuild_overlay();
3564 }
3565 {
3566 let mut reopened = SyncClient::open_path(
3567 "retained-native".to_owned(),
3568 &schema,
3569 ClientLimits::default(),
3570 path.to_str().unwrap(),
3571 )
3572 .unwrap();
3573 let row = reopened
3574 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3575 .unwrap();
3576 assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
3577 assert_eq!(
3578 reopened
3579 .commit_outcome("retained")
3580 .unwrap()
3581 .unwrap()
3582 .retained_rows
3583 .unwrap()[0]
3584 .server_row
3585 .as_ref()
3586 .unwrap()["title"],
3587 json!("server")
3588 );
3589 reopened
3590 .conn
3591 .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
3592 .unwrap();
3593 reopened.rebuild_overlay();
3594 assert!(reopened
3595 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3596 .is_none());
3597 let retained = reopened.commit_outcome("retained").unwrap().unwrap();
3598 assert!(retained.retained_rows.is_some());
3599 assert_eq!(
3600 retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
3601 json!("mine")
3602 );
3603 reopened
3604 .purge_local_data(&LocalDataPurgeInput {
3605 purge_id: "protected-purge".into(),
3606 targets: vec![LocalDataPurgeTarget {
3607 table: "tasks".into(),
3608 selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
3609 }],
3610 })
3611 .unwrap();
3612 assert!(reopened
3613 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3614 .is_none());
3615 assert!(reopened
3616 .commit_outcome("retained")
3617 .unwrap()
3618 .unwrap()
3619 .retained_rows
3620 .is_none());
3621 }
3622 std::fs::remove_file(path).unwrap();
3623 }
3624
3625 #[test]
3626 fn durable_conflict_outcome_and_resolution_survive_reopen() {
3627 let path = std::env::temp_dir().join(format!(
3628 "syncular-durable-outcome-{}.db",
3629 uuid::Uuid::new_v4()
3630 ));
3631 let schema = json!({
3632 "version": 1,
3633 "tables": [{
3634 "name": "tasks",
3635 "primaryKey": "id",
3636 "columns": [
3637 { "name": "id", "type": "string", "nullable": false },
3638 { "name": "project_id", "type": "string", "nullable": false }
3639 ],
3640 "scopes": [{ "pattern": "project:{project_id}" }]
3641 }]
3642 });
3643 let conflict = ConflictRecord {
3644 client_commit_id: "losing-commit".to_owned(),
3645 op_index: 0,
3646 table: "tasks".to_owned(),
3647 row_id: "t1".to_owned(),
3648 code: "sync.version_conflict".to_owned(),
3649 message: "stale base version".to_owned(),
3650 server_version: 2,
3651 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3652 conflict_columns: vec!["project_id".to_owned()],
3653 operation: Some(CommitOperation {
3654 table: "tasks".to_owned(),
3655 row_id: "t1".to_owned(),
3656 op: "upsert".to_owned(),
3657 base_version: Some(1),
3658 values: None,
3659 }),
3660 };
3661 let failed_operations = vec![
3662 OutboxOp {
3663 upsert: true,
3664 table: "tasks".to_owned(),
3665 row_id: "t1".to_owned(),
3666 base_version: Some(1),
3667 values: None,
3668 },
3669 OutboxOp {
3670 upsert: true,
3671 table: "tasks".to_owned(),
3672 row_id: "status-event-1".to_owned(),
3673 base_version: Some(0),
3674 values: None,
3675 },
3676 ];
3677
3678 {
3679 let mut first = SyncClient::open_path(
3680 "durable-native".to_owned(),
3681 &schema,
3682 ClientLimits::default(),
3683 path.to_str().expect("UTF-8 temp path"),
3684 )
3685 .expect("first open");
3686 first
3687 .begin_observation("test_outcome")
3688 .expect("begin outcome");
3689 first
3690 .persist_commit_outcome(
3691 "losing-commit",
3692 CommitOutcomeStatus::Conflict,
3693 &[CommitOperationOutcome::Conflict {
3694 conflict: conflict.clone(),
3695 }],
3696 Some(&failed_operations),
3697 )
3698 .expect("persist outcome");
3699 first.conflicts.push(conflict);
3700 first
3701 .finish_observation(
3702 "test_outcome",
3703 ChangeAccumulator {
3704 conflicts: true,
3705 outcomes: true,
3706 ..ChangeAccumulator::default()
3707 },
3708 )
3709 .expect("commit outcome");
3710 }
3711
3712 {
3713 let mut reopened = SyncClient::open_path(
3714 "durable-native".to_owned(),
3715 &schema,
3716 ClientLimits::default(),
3717 path.to_str().expect("UTF-8 temp path"),
3718 )
3719 .expect("reopen");
3720 assert_eq!(reopened.conflicts().len(), 1);
3721 assert_eq!(
3722 reopened.conflicts()[0].conflict_columns,
3723 vec!["project_id".to_owned()],
3724 "conflictColumns survives the journal round trip"
3725 );
3726 let outcome = reopened
3727 .commit_outcome("losing-commit")
3728 .expect("read outcome")
3729 .expect("outcome");
3730 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3731 let operations = outcome.operations.expect("aggregate envelope");
3732 assert_eq!(operations.len(), 2);
3733 assert_eq!(operations[1].row_id, "status-event-1");
3734 let resolved = reopened
3735 .resolve_commit_outcome(ResolveCommitOutcomeInput {
3736 client_commit_id: "losing-commit".to_owned(),
3737 resolution: CommitOutcomeResolution::ResolvedKeepServer,
3738 replacement_client_commit_id: None,
3739 })
3740 .expect("resolve");
3741 assert_eq!(
3742 resolved.resolution,
3743 CommitOutcomeResolution::ResolvedKeepServer
3744 );
3745 assert!(reopened.conflicts().is_empty());
3746 }
3747
3748 let reopened = SyncClient::open_path(
3749 "durable-native".to_owned(),
3750 &schema,
3751 ClientLimits::default(),
3752 path.to_str().expect("UTF-8 temp path"),
3753 )
3754 .expect("second reopen");
3755 assert!(reopened.conflicts().is_empty());
3756 assert_eq!(
3757 reopened
3758 .commit_outcome("losing-commit")
3759 .expect("read outcome")
3760 .expect("outcome")
3761 .resolution,
3762 CommitOutcomeResolution::ResolvedKeepServer
3763 );
3764 drop(reopened);
3765 std::fs::remove_file(path).expect("remove temp database");
3766 }
3767
3768 #[test]
3769 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3770 let path =
3771 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3772 let schema = json!({
3773 "version": 1,
3774 "tables": [{
3775 "name": "tasks",
3776 "primaryKey": "id",
3777 "columns": [
3778 { "name": "id", "type": "string", "nullable": false },
3779 { "name": "project_id", "type": "string", "nullable": false }
3780 ],
3781 "scopes": [{ "pattern": "project:{project_id}" }]
3782 }]
3783 });
3784 let mut client = SyncClient::open_path_with_identity(
3785 Some("sidecar-client".to_owned()),
3786 &schema,
3787 ClientLimits::default(),
3788 path.to_str().expect("UTF-8 temp path"),
3789 )
3790 .expect("open owner");
3791 let base = WindowBase {
3792 table: "tasks".to_owned(),
3793 variable: "project_id".to_owned(),
3794 fixed_scopes: Vec::new(),
3795 params: None,
3796 };
3797 client
3798 .set_window(&base, &["one".to_owned()])
3799 .expect("set window");
3800 client
3801 .mutate(vec![Mutation::Upsert {
3802 table: "tasks".to_owned(),
3803 values: Map::from_iter([
3804 ("id".to_owned(), Value::from("t1")),
3805 ("project_id".to_owned(), Value::from("one")),
3806 ]),
3807 base_version: None,
3808 }])
3809 .expect("local mutate");
3810
3811 let coverage = [WindowCoverage {
3812 base,
3813 units: vec!["one".to_owned(), "missing".to_owned()],
3814 }];
3815 let owner = client
3816 .query_snapshot(
3817 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3818 &[],
3819 &coverage,
3820 None,
3821 )
3822 .expect("owner snapshot");
3823 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3824 let sidecar = reader
3825 .query_snapshot(
3826 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3827 &[],
3828 &coverage,
3829 )
3830 .expect("sidecar snapshot");
3831
3832 assert_eq!(sidecar.revision, owner.revision);
3833 assert_eq!(sidecar.rows, owner.rows);
3834 assert_eq!(
3835 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3836 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3837 );
3838 assert_eq!(sidecar.revision, "2");
3839 assert_eq!(sidecar.rows[0]["id"], "t1");
3840 assert_eq!(sidecar.rows[0]["server_version"], -1);
3841 assert!(!sidecar.coverage.complete);
3842 assert_eq!(sidecar.coverage.pending.len(), 1);
3843 assert_eq!(sidecar.coverage.missing.len(), 1);
3844
3845 drop(reader);
3846 drop(client);
3847 std::fs::remove_file(path).expect("remove temp database");
3848 }
3849
3850 #[test]
3851 fn owned_query_failures_classify_order_bound_and_clear() {
3852 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3853 rusqlite::ffi::Error::new(266),
3854 Some("private SQLite prose".to_owned()),
3855 ));
3856 assert_eq!(io.code, Some("client.storage_io"));
3857 assert_eq!(io.sqlite_code, Some(266));
3858 assert_eq!(io.message, "local SQLite storage I/O failed");
3859 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3860 rusqlite::ffi::Error::new(26),
3861 None,
3862 ));
3863 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
3864
3865 let mut client = client();
3866 let generic = QueryReadFailure::from("private query prose".to_owned());
3867 client.record_query_read(
3868 QueryOwner {
3869 id: "first",
3870 tables: &["tasks", "tasks"],
3871 },
3872 Some(&generic),
3873 );
3874 client.record_query_read(
3875 QueryOwner {
3876 id: "second",
3877 tables: &["tasks"],
3878 },
3879 Some(&generic),
3880 );
3881 client.record_query_read(
3882 QueryOwner {
3883 id: "first",
3884 tables: &["docs"],
3885 },
3886 Some(&io),
3887 );
3888 let failures = &client
3889 .diagnostics_snapshot(&Default::default())
3890 .expect("diagnostics")
3891 .query_failures;
3892 assert_eq!(
3893 failures
3894 .iter()
3895 .map(|entry| entry.id.as_str())
3896 .collect::<Vec<_>>(),
3897 vec!["second", "first"]
3898 );
3899 assert_eq!(failures[1].tables, vec!["docs"]);
3900 assert_eq!(failures[1].code, "client.storage_io");
3901
3902 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
3903 client.record_query_read(
3904 QueryOwner {
3905 id: &format!("query-{index}"),
3906 tables: &["tasks"],
3907 },
3908 Some(&generic),
3909 );
3910 }
3911 let failures = &client
3912 .diagnostics_snapshot(&Default::default())
3913 .expect("bounded diagnostics")
3914 .query_failures;
3915 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
3916 assert_eq!(failures.last().expect("last failure").id, "query-256");
3917
3918 client
3919 .query_snapshot(
3920 "SELECT id FROM tasks",
3921 &[],
3922 &[],
3923 Some(QueryOwner {
3924 id: "query-256",
3925 tables: &["tasks"],
3926 }),
3927 )
3928 .expect("successful owned read");
3929 assert!(client
3930 .diagnostics_snapshot(&Default::default())
3931 .expect("cleared diagnostics")
3932 .query_failures
3933 .iter()
3934 .all(|entry| entry.id != "query-256"));
3935 }
3936
3937 #[test]
3938 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3939 let mut client = client();
3940 let image = Connection::open_in_memory().unwrap();
3941 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3942 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3943 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3944 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3945 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3946 let table = client.schema.table("tasks").unwrap().clone();
3947 let scopes = vec![("project_id".into(), vec!["p1".into()])];
3948 assert!(client
3949 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3950 .is_err());
3951 assert!(client.conn.is_autocommit());
3952 assert_eq!(
3953 client
3954 .query("SELECT count(*) AS n FROM tasks", &[])
3955 .unwrap()[0]["n"],
3956 1024
3957 );
3958 assert_eq!(client.drain_change_batches().len(), 1);
3959 image
3960 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3961 .unwrap();
3962 assert!(matches!(
3963 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3964 Ok(2050)
3965 ));
3966 assert_eq!(
3967 client
3968 .query("SELECT count(*) AS n FROM tasks", &[])
3969 .unwrap()[0]["n"],
3970 2050
3971 );
3972 assert_eq!(client.drain_change_batches().len(), 3);
3973 }
3974
3975 #[test]
3976 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3977 let mut client = client();
3978 let base = WindowBase {
3979 table: "tasks".into(),
3980 variable: "project_id".into(),
3981 fixed_scopes: Vec::new(),
3982 params: None,
3983 };
3984 client
3985 .set_window(&base, &["p1".into(), "p2".into()])
3986 .unwrap();
3987 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3988 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3989 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3990 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3991 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3992 client.drain_change_batches();
3993 assert!(client.set_window(&base, &["p2".into()]).is_err());
3994 assert!(client.conn.is_autocommit());
3995 assert_eq!(
3996 client
3997 .query("SELECT count(*) AS n FROM tasks", &[])
3998 .unwrap()[0]["n"],
3999 1027
4000 );
4001 assert_eq!(client.window_state(&base).units, vec!["p2"]);
4002 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
4003 assert_eq!(client.drain_change_batches().len(), 1);
4004 client
4005 .conn
4006 .execute_batch("DROP TRIGGER interrupt_evict")
4007 .unwrap();
4008 client.drain_pending_evictions().unwrap();
4009 assert!(client.load_pending_evictions().unwrap().is_empty());
4010 assert_eq!(
4011 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
4012 "held"
4013 );
4014 assert_eq!(client.drain_change_batches().len(), 2);
4015 }
4016
4017 #[test]
4018 fn eviction_marker_failures_roll_back_window_changes() {
4019 let mut client = client();
4020 let base = WindowBase {
4021 table: "tasks".into(),
4022 variable: "project_id".into(),
4023 fixed_scopes: Vec::new(),
4024 params: None,
4025 };
4026 client.set_window(&base, &["p1".into()]).unwrap();
4027 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4028 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4029 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4030 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4031 let revision = client.local_revision();
4032 assert!(client.set_window(&base, &[]).is_err());
4033 assert!(client.conn.is_autocommit());
4034 assert_eq!(client.local_revision(), revision);
4035 assert_eq!(
4036 client
4037 .query("SELECT count(*) AS n FROM tasks", &[])
4038 .unwrap()[0]["n"],
4039 2050
4040 );
4041 assert_eq!(client.window_state(&base).units, vec!["p1"]);
4042 client.conn.execute_batch("DROP TRIGGER fail_marker;
4043 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4044 assert!(client.set_window(&base, &[]).is_err());
4045 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
4046 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4047 assert!(client.drain_pending_evictions().is_err());
4048 assert!(client.conn.is_autocommit());
4049 assert_eq!(
4050 client
4051 .query("SELECT count(*) AS n FROM tasks", &[])
4052 .unwrap()[0]["n"],
4053 2
4054 );
4055 assert!(client.set_window(&base, &["p1".into()]).is_err());
4056 assert!(client.conn.is_autocommit());
4057 assert!(client.window_state(&base).units.is_empty());
4058 assert!(client.subs.is_empty());
4059 assert_eq!(
4060 client
4061 .query(
4062 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
4063 &[]
4064 )
4065 .unwrap()[0]["n"],
4066 1
4067 );
4068 client
4069 .conn
4070 .execute_batch("DROP TRIGGER fail_marker")
4071 .unwrap();
4072 client.set_window(&base, &["p1".into()]).unwrap();
4073 assert_eq!(client.window_state(&base).units, vec!["p1"]);
4074 }
4075
4076 #[test]
4077 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
4078 let mut client = client();
4079 client
4080 .conn
4081 .execute(
4082 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
4083 [],
4084 )
4085 .unwrap();
4086 assert_eq!(
4087 client.drain_pending_evictions().unwrap_err(),
4088 "sync.local_corrupt: invalid pending eviction scopes"
4089 );
4090 assert!(client.conn.is_autocommit());
4091 assert!(client.enqueue_startup_sync_if_needed().is_err());
4092 }
4093
4094 #[test]
4095 fn image_chunks_reject_null_primary_keys() {
4096 let mut client = client();
4097 let image = Connection::open_in_memory().unwrap();
4098 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4099 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
4100 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
4101 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4102 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
4103 let table = client.schema.table("tasks").unwrap().clone();
4104 assert!(client
4105 .apply_sqlite_image(
4106 &image,
4107 &table,
4108 true,
4109 &[("project_id".into(), vec!["p1".into()])],
4110 1025,
4111 7,
4112 "digest"
4113 )
4114 .is_err());
4115 assert!(client.conn.is_autocommit());
4116 assert_eq!(
4117 client
4118 .query("SELECT count(*) AS n FROM tasks", &[])
4119 .unwrap()[0]["n"],
4120 0
4121 );
4122 }
4123
4124 #[test]
4125 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
4126 for images in [false, true] {
4127 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4128 "name":name,"primaryKey":"id","columns":[
4129 {"name":"id","type":"string","nullable":false},
4130 {"name":"project_id","type":"string","nullable":false},
4131 {"name":"title","type":"string","nullable":false}],
4132 "scopes":[{"pattern":"project:{project_id}"}],
4133 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
4134 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4135 })))});
4136 let mut actual =
4137 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
4138 let mut reference =
4139 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
4140 let scopes = vec![("project_id".into(), vec!["p1".into()])];
4141 for client in [&mut actual, &mut reference] {
4142 client
4143 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
4144 .unwrap();
4145 client.subs[0].effective = Some(scopes.clone());
4146 for (table, id, title) in [
4147 ("tasks", "a", "original"),
4148 ("tasks", "b", "occupied"),
4149 ("tasks", "c", "deleted"),
4150 ("notes", "n", "untouched"),
4151 ] {
4152 client
4153 .write_base_row(
4154 table,
4155 &vec![
4156 Some(ColumnValue::String(id.into())),
4157 Some(ColumnValue::String("p1".into())),
4158 Some(ColumnValue::String(title.into())),
4159 ],
4160 1,
4161 )
4162 .unwrap();
4163 }
4164 client.rebuild_overlay_if_dirty();
4165 client
4166 .mutate(vec![
4167 Mutation::Upsert {
4168 table: "tasks".into(),
4169 values: Map::from_iter([
4170 ("id".into(), Value::from("a")),
4171 ("project_id".into(), Value::from("p1")),
4172 ("title".into(), Value::from("available")),
4173 ]),
4174 base_version: None,
4175 },
4176 Mutation::Delete {
4177 table: "tasks".into(),
4178 row_id: "c".into(),
4179 base_version: None,
4180 },
4181 Mutation::Upsert {
4182 table: "tasks".into(),
4183 values: Map::from_iter([
4184 ("id".into(), Value::from("local")),
4185 ("project_id".into(), Value::from("p1")),
4186 ("title".into(), Value::from("local value")),
4187 ]),
4188 base_version: None,
4189 },
4190 Mutation::Upsert {
4191 table: "notes".into(),
4192 values: Map::from_iter([
4193 ("id".into(), Value::from("n")),
4194 ("project_id".into(), Value::from("p1")),
4195 ("title".into(), Value::from("pending note")),
4196 ]),
4197 base_version: None,
4198 },
4199 ])
4200 .unwrap();
4201 }
4202 let pending = actual.pending_commit_ids();
4203 actual.overlay_rebuild_count.set(0);
4204 for (id, title) in [
4205 ("b", "available"),
4206 ("b", "occupied"),
4207 ("a", "server original"),
4208 ("d", "local value"),
4209 ("d", "released"),
4210 ("c", "redelivered"),
4211 ] {
4212 let table = actual.schema.table("tasks").unwrap().clone();
4213 let values = vec![
4214 Some(ColumnValue::String(id.into())),
4215 Some(ColumnValue::String("p1".into())),
4216 Some(ColumnValue::String(title.into())),
4217 ];
4218 if images {
4219 let image = Connection::open_in_memory().unwrap();
4220 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4221 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4222 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4223 image
4224 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
4225 .unwrap();
4226 assert!(matches!(
4227 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
4228 Ok(1)
4229 ));
4230 } else {
4231 let segment = RowsSegment {
4232 table: "tasks".into(),
4233 schema_version: 1,
4234 columns: table.wire_columns.clone(),
4235 blocks: vec![vec![ssp2::segment::SegmentRow {
4236 server_version: 2,
4237 values: values.clone(),
4238 }]],
4239 };
4240 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4241 }
4242 reference.write_base_row("tasks", &values, 2).unwrap();
4243 reference.rebuild_overlay();
4244 for table in ["tasks", "notes"] {
4245 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")] {
4246 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4247 }
4248 }
4249 assert_eq!(actual.pending_commit_ids(), pending);
4250 }
4251 assert_eq!(
4252 actual.overlay_rebuild_count.get(),
4253 0,
4254 "each chunk must leave unrelated tables alone"
4255 );
4256 let before = actual
4257 .query("SELECT * FROM tasks ORDER BY id", &[])
4258 .unwrap();
4259 let fts_before = actual
4260 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4261 .unwrap();
4262 let revision = actual.local_revision();
4263 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4264 let table = actual.schema.table("tasks").unwrap().clone();
4265 let segment = RowsSegment {
4266 table: "tasks".into(),
4267 schema_version: 1,
4268 columns: table.wire_columns.clone(),
4269 blocks: vec![vec![ssp2::segment::SegmentRow {
4270 server_version: 3,
4271 values: vec![
4272 Some(ColumnValue::String("b".into())),
4273 Some(ColumnValue::String("p1".into())),
4274 Some(ColumnValue::String("fresh".into())),
4275 ],
4276 }]],
4277 };
4278 assert!(actual.apply_segment(0, &segment, false).is_err());
4279 assert!(actual.conn.is_autocommit());
4280 assert_eq!(
4281 actual
4282 .query("SELECT * FROM tasks ORDER BY id", &[])
4283 .unwrap(),
4284 before
4285 );
4286 assert_eq!(
4287 actual
4288 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4289 .unwrap(),
4290 fts_before
4291 );
4292 assert_eq!(actual.local_revision(), revision);
4293 assert_eq!(actual.pending_commit_ids(), pending);
4294 }
4295 }
4296
4297 #[test]
4298 fn import_reconciliation_preserves_typed_pending_primary_keys() {
4299 for (kind, a, b) in [
4300 ("string", json!("a"), json!("b")),
4301 ("integer", json!(1), json!(2)),
4302 ("boolean", json!(true), json!(false)),
4303 ("json", json!("1"), json!("2")),
4304 ] {
4305 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}]}]});
4306 for images in [false, true] {
4307 let mut client =
4308 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
4309 let table = client.schema.table("tasks").unwrap().clone();
4310 client
4311 .subscribe("tasks".into(), "tasks".into(), vec![], None)
4312 .unwrap();
4313 for (id, title) in [(&a, "original"), (&b, "occupied")] {
4314 client
4315 .write_base_row(
4316 "tasks",
4317 &vec![
4318 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
4319 Some(ColumnValue::String(title.into())),
4320 ],
4321 1,
4322 )
4323 .unwrap();
4324 }
4325 client.rebuild_overlay_if_dirty();
4326 client
4327 .mutate(vec![Mutation::Upsert {
4328 table: "tasks".into(),
4329 values: Map::from_iter([
4330 ("id".into(), a.clone()),
4331 ("title".into(), json!("available")),
4332 ]),
4333 base_version: None,
4334 }])
4335 .unwrap();
4336 client.overlay_rebuild_count.set(0);
4337 for (title, expected) in [("available", "original"), ("occupied", "available")] {
4338 let values = vec![
4339 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
4340 Some(ColumnValue::String(title.into())),
4341 ];
4342 if images {
4343 let image = Connection::open_in_memory().unwrap();
4344 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4345 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4346 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4347 image
4348 .execute(
4349 "INSERT INTO tasks VALUES (?1,?2,2)",
4350 rusqlite::params![RowParam::Cell(&values[0]), title],
4351 )
4352 .unwrap();
4353 assert!(
4354 matches!(
4355 client.apply_sqlite_image(
4356 &image,
4357 &table,
4358 false,
4359 &[],
4360 1,
4361 7,
4362 "digest"
4363 ),
4364 Ok(1)
4365 ),
4366 "{kind}, images={images}"
4367 );
4368 } else {
4369 let segment = RowsSegment {
4370 table: "tasks".into(),
4371 schema_version: 1,
4372 columns: table.wire_columns.clone(),
4373 blocks: vec![vec![ssp2::segment::SegmentRow {
4374 server_version: 2,
4375 values,
4376 }]],
4377 };
4378 assert!(
4379 matches!(client.apply_segment(0, &segment, false), Ok(1)),
4380 "{kind}"
4381 );
4382 }
4383 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
4384 let actual: String = client
4385 .conn
4386 .query_row(
4387 "SELECT title FROM tasks WHERE id=?1",
4388 [RowParam::Cell(&key)],
4389 |row| row.get(0),
4390 )
4391 .unwrap();
4392 assert_eq!(actual, expected, "{kind}, images={images}");
4393 }
4394 assert_eq!(client.overlay_rebuild_count.get(), 0);
4395 }
4396 }
4397 }
4398
4399 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
4400 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4401 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4402 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4403 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4404 })))});
4405 let mut client =
4406 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4407 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();
4408 client.rebuild_overlay();
4409 client
4410 .mutate(vec![Mutation::Upsert {
4411 table: "tasks".into(),
4412 values: Map::from_iter([
4413 ("id".into(), json!("offline")),
4414 ("project_id".into(), json!("p1")),
4415 ("title".into(), json!("pending edit")),
4416 ]),
4417 base_version: None,
4418 }])
4419 .unwrap();
4420 let image = Connection::open_in_memory().unwrap();
4421 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4422 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4423 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();
4424 image
4425 .execute(
4426 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4427 [rows],
4428 )
4429 .unwrap();
4430 client.overlay_rebuild_count.set(0);
4431 let table = client.schema.table("tasks").unwrap().clone();
4432 (client, image, table)
4433 }
4434
4435 #[test]
4436 fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
4437 let (mut client, image, table) = unique_import_fixture(1);
4438 image
4439 .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
4440 .unwrap();
4441 client
4442 .conn
4443 .execute_batch("PRAGMA max_page_count = 1")
4444 .unwrap();
4445 let effective = [("project_id".into(), vec!["p1".into()])];
4446 assert!(client
4447 .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
4448 .is_err());
4449 let failure = client.storage_failure.borrow().clone().unwrap();
4450 assert_eq!(failure.code, Some("client.storage_full"));
4451 assert_eq!(failure.sqlite_code, Some(13));
4452 assert!(!failure.message.contains("rollback"));
4453 assert!(client.conn.is_autocommit());
4454 client
4455 .conn
4456 .execute_batch("PRAGMA max_page_count = 1073741823")
4457 .unwrap();
4458 assert!(matches!(
4459 client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
4460 Ok(1)
4461 ));
4462 assert!(client.conn.is_autocommit());
4463 }
4464
4465 #[test]
4466 fn large_unique_import_copies_no_unrelated_tables() {
4467 let (mut client, image, table) = unique_import_fixture(10240);
4468 let pending = client.pending_commit_ids();
4469 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4470 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4471 assert!(matches!(
4472 client.apply_sqlite_image(
4473 &image,
4474 &table,
4475 true,
4476 &[("project_id".into(), vec!["p1".into()])],
4477 10240,
4478 7,
4479 "digest"
4480 ),
4481 Ok(10240)
4482 ));
4483 assert_eq!(client.overlay_rebuild_count.get(), 0);
4484 assert_eq!(client.pending_commit_ids(), pending);
4485 assert_eq!(
4486 client
4487 .query("SELECT count(*) AS n FROM tasks", &[])
4488 .unwrap()[0]["n"],
4489 10241
4490 );
4491 assert_eq!(
4492 client
4493 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4494 .unwrap()[0]["n"],
4495 10241
4496 );
4497 assert_eq!(
4498 client
4499 .query("SELECT count(*) AS n FROM notes_fts", &[])
4500 .unwrap()[0]["n"],
4501 8192
4502 );
4503 }
4504
4505 #[test]
4506 #[ignore = "manual scaling benchmark; see bench/README.md"]
4507 fn benchmark_unique_image_import() {
4508 let mut samples = Vec::new();
4509 for rows in [10240, 51200, 102400] {
4510 for trial in 0..4 {
4511 let (mut client, image, table) = unique_import_fixture(rows);
4512 let start = std::time::Instant::now();
4513 assert!(
4514 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)
4515 );
4516 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4517 assert_eq!(
4518 client
4519 .query("SELECT count(*) AS n FROM tasks", &[])
4520 .unwrap()[0]["n"],
4521 rows + 1
4522 );
4523 assert_eq!(
4524 client
4525 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4526 .unwrap()[0]["n"],
4527 rows + 1
4528 );
4529 assert_eq!(
4530 client
4531 .query("SELECT count(*) AS n FROM notes_fts", &[])
4532 .unwrap()[0]["n"],
4533 8192
4534 );
4535 assert_eq!(client.pending_commit_ids().len(), 1);
4536 if trial > 0 {
4537 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4538 }
4539 }
4540 }
4541 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});
4542 let output = serde_json::to_string_pretty(&artifact).unwrap();
4543 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4544 use std::io::Write;
4545 let mut file = std::fs::OpenOptions::new()
4546 .write(true)
4547 .create_new(true)
4548 .open(path)
4549 .unwrap();
4550 file.write_all(output.as_bytes()).unwrap();
4551 } else {
4552 println!("{output}");
4553 }
4554 }
4555
4556 #[test]
4557 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4558 let schema = json!({
4559 "version": 1,
4560 "tables": [{
4561 "name": "catalogue_codes",
4562 "primaryKey": "id",
4563 "columns": [
4564 { "name": "id", "type": "string", "nullable": false },
4565 { "name": "release_id", "type": "string", "nullable": false },
4566 { "name": "code", "type": "string", "nullable": false },
4567 { "name": "title", "type": "string", "nullable": false }
4568 ],
4569 "scopes": [{ "pattern": "release:{release_id}" }],
4570 "ftsIndexes": [{
4571 "name": "catalogue_codes_fts",
4572 "columns": ["code", "title"],
4573 "tokenize": "unicode61 remove_diacritics 2"
4574 }]
4575 }]
4576 });
4577 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4578 .expect("FTS5 client");
4579 let insert_trigger: String = client
4580 .conn
4581 .query_row(
4582 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4583 [],
4584 |row| row.get(0),
4585 )
4586 .expect("insert trigger");
4587 let replace_guard: String = client
4588 .conn
4589 .query_row(
4590 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4591 [],
4592 |row| row.get(0),
4593 )
4594 .expect("replace guard");
4595 assert!(!insert_trigger.contains("DELETE FROM"));
4596 assert!(replace_guard.contains("BEFORE INSERT"));
4597 assert!(replace_guard.contains("WHEN EXISTS"));
4598 let search = |client: &SyncClient, query: &str| {
4599 client
4600 .query(
4601 "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",
4602 &[Value::from(query)],
4603 )
4604 .expect("FTS query")
4605 };
4606
4607 client
4608 .mutate(vec![Mutation::Upsert {
4609 table: "catalogue_codes".to_owned(),
4610 values: Map::from_iter([
4611 ("id".to_owned(), Value::from("c1")),
4612 ("release_id".to_owned(), Value::from("r1")),
4613 ("code".to_owned(), Value::from("A01")),
4614 ("title".to_owned(), Value::from("Cholera")),
4615 ]),
4616 base_version: None,
4617 }])
4618 .expect("insert code");
4619 assert_eq!(search(&client, "cholera").len(), 1);
4620 let prior = client
4621 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4622 .unwrap();
4623 client
4624 .conn
4625 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4626 .unwrap();
4627 client.create_synced_tables().unwrap();
4628 assert_eq!(
4629 client
4630 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4631 .unwrap(),
4632 prior
4633 );
4634 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();
4635 let rendered = serde_json::to_string(&plan).unwrap();
4636 assert!(rendered.contains("COVERING INDEX"));
4637 assert!(rendered.contains("INDEX 0:="));
4638 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4639 assert_eq!(search(&client, "cholera").len(), 1);
4640
4641 client
4642 .mutate(vec![Mutation::Upsert {
4643 table: "catalogue_codes".to_owned(),
4644 values: Map::from_iter([
4645 ("id".to_owned(), Value::from("c1")),
4646 ("release_id".to_owned(), Value::from("r1")),
4647 ("code".to_owned(), Value::from("A01")),
4648 ("title".to_owned(), Value::from("Enteric infection")),
4649 ]),
4650 base_version: None,
4651 }])
4652 .expect("update code");
4653 assert!(search(&client, "cholera").is_empty());
4654 assert_eq!(search(&client, "enteric").len(), 1);
4655
4656 client
4657 .mutate(vec![Mutation::Delete {
4658 table: "catalogue_codes".to_owned(),
4659 row_id: "c1".to_owned(),
4660 base_version: None,
4661 }])
4662 .expect("delete code");
4663 assert!(search(&client, "enteric").is_empty());
4664 }
4665
4666 #[test]
4667 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4668 let schema = json!({
4669 "version": 1,
4670 "tables": [{
4671 "name": "patient_notes",
4672 "primaryKey": "id",
4673 "columns": [
4674 { "name": "id", "type": "string", "nullable": false },
4675 { "name": "practice_id", "type": "string", "nullable": false },
4676 { "name": "encryption_key_id", "type": "string", "nullable": false },
4677 { "name": "title", "type": "string", "nullable": false }
4678 ],
4679 "scopes": [{ "pattern": "practice:{practice_id}" }],
4680 "ftsIndexes": [{
4681 "name": "patient_notes_fts",
4682 "columns": ["title"],
4683 "tokenize": "unicode61 remove_diacritics 2"
4684 }]
4685 }]
4686 });
4687 let mut client = SyncClient::new(
4688 "local-purge-test".to_owned(),
4689 &schema,
4690 ClientLimits::default(),
4691 )
4692 .expect("local purge client");
4693 client
4694 .conn
4695 .execute(
4696 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4697 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4698 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4699 [],
4700 )
4701 .expect("seed base rows");
4702 client.overlay_dirty.set(true);
4703 client.rebuild_overlay_if_dirty();
4704
4705 let note = |id: &str, key_id: &str, title: &str| {
4706 Map::from_iter([
4707 ("id".to_owned(), Value::from(id)),
4708 ("practice_id".to_owned(), Value::from("practice-1")),
4709 ("encryption_key_id".to_owned(), Value::from(key_id)),
4710 ("title".to_owned(), Value::from(title)),
4711 ])
4712 };
4713 let doomed = client
4714 .mutate(vec![
4715 Mutation::Upsert {
4716 table: "patient_notes".to_owned(),
4717 values: note("target", "key-revoked", "Target changed"),
4718 base_version: None,
4719 },
4720 Mutation::Upsert {
4721 table: "patient_notes".to_owned(),
4722 values: note("unrelated", "key-held", "Unrelated changed"),
4723 base_version: None,
4724 },
4725 ])
4726 .expect("doomed commit");
4727 let kept = client
4728 .mutate(vec![Mutation::Upsert {
4729 table: "patient_notes".to_owned(),
4730 values: note("kept", "key-held", "Kept optimistic"),
4731 base_version: None,
4732 }])
4733 .expect("kept commit");
4734 client.drain_change_batches();
4735
4736 let input = LocalDataPurgeInput {
4737 purge_id: "purge-001".to_owned(),
4738 targets: vec![LocalDataPurgeTarget {
4739 table: "patient_notes".to_owned(),
4740 selectors: BTreeMap::from([(
4741 "encryption_key_id".to_owned(),
4742 vec!["key-revoked".to_owned()],
4743 )]),
4744 }],
4745 };
4746 assert_eq!(
4747 client.purge_local_data(&input).expect("apply purge"),
4748 LocalDataPurgeResult {
4749 already_applied: false,
4750 purged_rows: 1,
4751 dropped_commits: 1,
4752 }
4753 );
4754 let rows = client
4755 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
4756 .expect("visible rows");
4757 assert_eq!(rows.len(), 2);
4758 assert_eq!(rows[0]["id"], "kept");
4759 assert_eq!(rows[1]["id"], "unrelated");
4760 assert_eq!(rows[1]["title"], "Unrelated original");
4761 let fts = client
4762 .query(
4763 "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'",
4764 &[],
4765 )
4766 .expect("fts query");
4767 assert!(fts.is_empty());
4768 assert_eq!(client.outbox.len(), 1);
4769 assert_eq!(client.outbox[0].client_commit_id, kept);
4770 let outcome = client
4771 .commit_outcome(&doomed)
4772 .expect("read doomed outcome")
4773 .expect("doomed outcome");
4774 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
4775 match &outcome.results[0] {
4776 CommitOperationOutcome::Error { rejection } => {
4777 assert_eq!(rejection.code, "client.local_data_purged");
4778 assert_eq!(rejection.client_commit_id, doomed);
4779 }
4780 other => panic!("expected local purge rejection, got {other:?}"),
4781 }
4782 assert_eq!(client.drain_change_batches().len(), 1);
4783 assert_eq!(
4784 client.purge_local_data(&input).expect("retry purge"),
4785 LocalDataPurgeResult {
4786 already_applied: true,
4787 purged_rows: 0,
4788 dropped_commits: 0,
4789 }
4790 );
4791 let conflicting = LocalDataPurgeInput {
4792 purge_id: input.purge_id.clone(),
4793 targets: vec![LocalDataPurgeTarget {
4794 table: "patient_notes".to_owned(),
4795 selectors: BTreeMap::from([(
4796 "encryption_key_id".to_owned(),
4797 vec!["key-held".to_owned()],
4798 )]),
4799 }],
4800 };
4801 assert!(client
4802 .purge_local_data(&conflicting)
4803 .expect_err("id collision must fail")
4804 .contains("already used with a different plan"));
4805 }
4806}
4807
4808impl SubState {
4809 fn name(self) -> &'static str {
4810 match self {
4811 SubState::Active => "active",
4812 SubState::Revoked => "revoked",
4813 SubState::Failed => "failed",
4814 }
4815 }
4816
4817 fn parse(value: &str) -> Self {
4818 match value {
4819 "revoked" => Self::Revoked,
4820 "failed" => Self::Failed,
4821 _ => Self::Active,
4822 }
4823 }
4824}
4825
4826#[derive(Debug, Clone)]
4827struct Subscription {
4828 id: String,
4829 table: String,
4830 requested: Vec<(String, Vec<String>)>,
4831 params: Option<String>,
4832 cursor: i64,
4833 bootstrap_state: Option<String>,
4835 state: SubState,
4836 reason_code: Option<String>,
4837 effective: Option<Vec<(String, Vec<String>)>>,
4840 synced_once: bool,
4841}
4842
4843#[derive(Debug, Clone)]
4844struct OutboxOp {
4845 upsert: bool,
4846 table: String,
4847 row_id: String,
4848 base_version: Option<i64>,
4849 values: Option<Map<String, Value>>,
4853}
4854
4855impl From<&OutboxOp> for CommitOperation {
4856 fn from(operation: &OutboxOp) -> Self {
4857 Self {
4858 table: operation.table.clone(),
4859 row_id: operation.row_id.clone(),
4860 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4861 base_version: operation.base_version,
4862 values: operation.values.clone(),
4863 }
4864 }
4865}
4866
4867#[derive(Debug, Clone)]
4868struct OutboxCommit {
4869 client_commit_id: String,
4870 ops: Vec<OutboxOp>,
4871}
4872
4873fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
4877 commit
4878 .ops
4879 .iter()
4880 .filter(|operation| operation.upsert)
4881 .map(|operation| {
4882 (
4883 operation.table.as_str(),
4884 operation
4885 .values
4886 .as_ref()
4887 .map(|values| values.keys().map(String::as_str).collect())
4888 .unwrap_or_default(),
4889 )
4890 })
4891 .collect()
4892}
4893
4894type FailedCommit = (OutboxCommit, Map<String, Value>);
4895
4896#[derive(Debug, Clone)]
4897struct CompiledLocalDataPurgeTarget {
4898 table: String,
4899 selectors: Vec<(String, Vec<String>)>,
4900}
4901
4902struct StoredCommitOutcomeRow {
4903 sequence: i64,
4904 client_commit_id: String,
4905 status: String,
4906 recorded_at_ms: i64,
4907 results_json: String,
4908 operations_json: Option<String>,
4909 resolution: String,
4910 resolved_at_ms: Option<i64>,
4911 replacement_client_commit_id: Option<String>,
4912}
4913
4914enum SectionError {
4917 FailClosed,
4918 Abort(String, String),
4919}
4920
4921struct RequestMeta {
4922 pushed_ids: Vec<String>,
4923 fresh: Vec<(String, bool)>,
4926 accept: u8,
4927 deferred_commits: usize,
4931}
4932
4933#[derive(Default)]
4934struct ChangeAccumulator {
4935 tables: BTreeMap<String, Option<BTreeSet<String>>>,
4937 windows: BTreeMap<(String, String), BTreeSet<String>>,
4938 status: bool,
4939 conflicts: bool,
4940 rejections: bool,
4941 outcomes: bool,
4942}
4943
4944impl ChangeAccumulator {
4945 fn table(&mut self, table: &str) {
4946 self.tables.insert(table.to_owned(), None);
4947 }
4948
4949 fn scope(&mut self, table: &str, key: String) {
4950 match self.tables.get_mut(table) {
4951 Some(None) => {}
4952 Some(Some(keys)) => {
4953 keys.insert(key);
4954 }
4955 None => {
4956 self.tables
4957 .insert(table.to_owned(), Some(BTreeSet::from([key])));
4958 }
4959 }
4960 }
4961
4962 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4963 self.windows
4964 .entry((base_key.to_owned(), table.to_owned()))
4965 .or_default()
4966 .insert(unit.to_owned());
4967 }
4968
4969 fn touched(&self) -> bool {
4970 !self.tables.is_empty()
4971 || !self.windows.is_empty()
4972 || self.status
4973 || self.conflicts
4974 || self.rejections
4975 || self.outcomes
4976 }
4977}
4978
4979const PUSH_OPS_PER_REQUEST: usize = 500;
4985const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4986const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4987const MAX_LOCAL_PURGE_VALUES: usize = 128;
4988const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4989pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4990
4991pub struct SyncClient {
4992 #[cfg(feature = "bench-internals")]
4993 benchmark_phases: Recorder,
4994 conn: Connection,
4995 schema: ClientSchema,
4996 client_id: String,
4997 limits: ClientLimits,
4998 subs: Vec<Subscription>,
4999 outbox: Vec<OutboxCommit>,
5000 failed_commits: Vec<FailedCommit>,
5001 retain_failed_commits: bool,
5002 conflicts: Vec<ConflictRecord>,
5003 rejections: Vec<RejectionRecord>,
5004 schema_floor: Option<SchemaFloor>,
5005 lease_state: Option<LeaseState>,
5007 stopped: bool,
5009 upgrading: bool,
5011 sync_needed: bool,
5013 realtime_policy: RealtimePolicy,
5016 realtime_state: RealtimeState,
5017 realtime_reason_code: Option<String>,
5018 realtime_retry_delay_ms: Option<u64>,
5019 presence: HashMap<String, HashMap<String, PresencePeer>>,
5021 now_ms: Option<i64>,
5024 encryption: crate::values::EncryptionConfig,
5028 security_preflight: bool,
5031 previous_version: Option<PreviousVersionContextConfig>,
5035 insert_sql: RefCell<HashMap<String, String>>,
5041 overlay_dirty: Cell<bool>,
5045 storage_failure: RefCell<Option<QueryReadFailure>>,
5047 #[cfg(test)]
5050 overlay_rebuild_count: Cell<usize>,
5051 #[cfg(test)]
5054 outcome_prune_count: Cell<usize>,
5055 progress: ProgressObserver,
5057 change_queue: VecDeque<ClientChangeBatch>,
5058 sync_intent_queue: VecDeque<SyncIntent>,
5059 retry_delay_ms: u64,
5061 round_retry_delay_ms: Option<u64>,
5063 last_round: Option<DiagnosticLastRound>,
5064 last_change: Option<DiagnosticLastChange>,
5065 query_failures: Vec<DiagnosticQueryFailure>,
5067}
5068
5069pub(crate) fn quote_ident(name: &str) -> String {
5070 format!("\"{}\"", name.replace('"', "\"\""))
5071}
5072pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
5076 conn.query_row(
5077 "SELECT value FROM _syncular_meta WHERE key = ?1",
5078 rusqlite::params![key],
5079 |row| row.get::<_, String>(0),
5080 )
5081 .ok()
5082}
5083
5084pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
5085 let _ = conn.execute(
5086 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
5087 rusqlite::params![key, value],
5088 );
5089}
5090
5091pub(crate) fn meta_delete(conn: &Connection, key: &str) {
5092 let _ = conn.execute(
5093 "DELETE FROM _syncular_meta WHERE key = ?1",
5094 rusqlite::params![key],
5095 );
5096}
5097
5098fn row_id_predicate(table: &TableSchema) -> String {
5100 let key = quote_ident(&table.primary_key);
5101 let text_match = format!("CAST({key} AS TEXT) = ?1");
5102 match table.columns[table.pk_index].ty {
5103 ColumnType::String | ColumnType::Json => {
5104 format!("{key} = ?1 AND {text_match}")
5105 }
5106 ColumnType::Integer | ColumnType::Boolean => {
5110 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
5111 }
5112 _ => text_match,
5115 }
5116}
5117
5118fn is_local_operation_code_like(value: &str) -> bool {
5119 let bytes = value.as_bytes();
5120 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
5121 && bytes
5122 .iter()
5123 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
5124}
5125
5126fn base_table(name: &str) -> String {
5127 quote_ident(&format!("_syncular_base_{name}"))
5128}
5129
5130type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
5135
5136fn window_base_key(base: &WindowBase) -> String {
5137 format!(
5138 "{}\0{}\0{}",
5139 base.table,
5140 base.variable,
5141 canonical_scope_json(&base.fixed_scopes)
5142 )
5143}
5144
5145fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
5147 let mut scopes = base.fixed_scopes.clone();
5148 scopes.retain(|(k, _)| k != &base.variable);
5149 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
5150 scopes
5151}
5152
5153fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
5157 let canonical = canonical_scope_json(&unit_scopes(base, unit));
5158 let digest = Sha256::digest(canonical.as_bytes());
5159 let hex = bytes_to_hex(&digest);
5160 format!("w:{}:{}", base.table, &hex[..16])
5161}
5162
5163fn visible_table(name: &str) -> String {
5164 quote_ident(name)
5165}
5166
5167const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
5168
5169fn is_synced_table_name(name: &str) -> bool {
5172 if name.starts_with("sqlite_") {
5173 return false;
5174 }
5175 if name.starts_with("_syncular_base_") {
5176 return true; }
5178 !name.starts_with("_syncular_")
5180}
5181
5182fn blob_id_for(bytes: &[u8]) -> String {
5184 let digest = Sha256::digest(bytes);
5185 format!("sha256:{}", bytes_to_hex(&digest))
5186}
5187
5188enum RowParam<'a> {
5192 Cell(&'a Option<ColumnValue>),
5193 Version(i64),
5194}
5195
5196impl rusqlite::ToSql for RowParam<'_> {
5197 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
5198 Ok(match self {
5199 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
5200 RowParam::Cell(cell) => match cell {
5201 None => ToSqlOutput::Owned(SqlValue::Null),
5202 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
5203 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
5204 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
5205 Some(ColumnValue::Boolean(b)) => {
5206 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
5207 }
5208 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
5209 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
5210 }
5211 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
5213 ToSqlOutput::Borrowed(ValueRef::Blob(b))
5214 }
5215 },
5216 })
5217 }
5218}
5219
5220fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
5222 match param.to_sql().map_err(|error| error.to_string())? {
5223 ToSqlOutput::Owned(value) => Ok(value),
5224 ToSqlOutput::Borrowed(value) => {
5225 SqlValue::try_from(value).map_err(|error| error.to_string())
5226 }
5227 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
5228 }
5229}
5230
5231fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
5239 use ssp2::segment::ColumnType;
5240 let mismatch = || {
5241 Err(format!(
5242 "image column {:?} holds a value of the wrong type",
5243 column.name
5244 ))
5245 };
5246 match value {
5247 ValueRef::Null => {
5248 if !column.nullable {
5249 return Err(format!(
5250 "image column {:?} is NULL but not nullable",
5251 column.name
5252 ));
5253 }
5254 Ok(ToSqlOutput::Owned(SqlValue::Null))
5255 }
5256 ValueRef::Integer(i) => match column.ty {
5257 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
5258 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
5259 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
5260 _ => mismatch(),
5261 },
5262 ValueRef::Real(_) => match column.ty {
5263 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
5264 _ => mismatch(),
5265 },
5266 ValueRef::Text(t) => {
5267 std::str::from_utf8(t)
5268 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
5269 match column.ty {
5270 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
5271 Ok(ToSqlOutput::Borrowed(value))
5272 }
5273 _ => mismatch(),
5274 }
5275 }
5276 ValueRef::Blob(_) => match column.ty {
5277 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
5279 _ => mismatch(),
5280 },
5281 }
5282}
5283
5284pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
5285 use rusqlite::types::ValueRef;
5286 match value {
5287 ValueRef::Null => Value::Null,
5288 ValueRef::Integer(i) => match column.ty {
5289 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
5290 ssp2::segment::ColumnType::Float => {
5291 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
5292 }
5293 _ => Value::from(i),
5294 },
5295 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5296 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5297 ValueRef::Blob(b) => {
5298 let mut map = Map::new();
5299 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5300 Value::Object(map)
5301 }
5302 }
5303}
5304
5305fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
5309 Ok(match value {
5310 Value::Null => SqlValue::Null,
5311 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
5312 Value::Number(n) => {
5313 if let Some(i) = n.as_i64() {
5314 SqlValue::Integer(i)
5315 } else if let Some(f) = n.as_f64() {
5316 SqlValue::Real(f)
5317 } else {
5318 return Err(format!("query param number {n} is out of range"));
5319 }
5320 }
5321 Value::String(s) => SqlValue::Text(s.clone()),
5322 Value::Object(_) => {
5323 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
5324 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
5325 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
5326 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
5327 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
5328 })?)
5329 } else {
5330 return Err(
5331 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
5332 .to_owned(),
5333 );
5334 }
5335 }
5336 Value::Array(_) => return Err("query array params are not supported".to_owned()),
5337 })
5338}
5339
5340fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
5345 use rusqlite::types::ValueRef;
5346 match value {
5347 ValueRef::Null => Value::Null,
5348 ValueRef::Integer(i) => {
5349 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
5353 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
5354 Value::from(i)
5355 } else {
5356 let mut map = Map::new();
5357 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
5358 Value::Object(map)
5359 }
5360 }
5361 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5362 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5363 ValueRef::Blob(b) => {
5364 let mut map = Map::new();
5365 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5366 Value::Object(map)
5367 }
5368 }
5369}
5370
5371fn query_connection(
5373 conn: &Connection,
5374 sql: &str,
5375 params: &[QueryValue],
5376) -> Result<Vec<QueryRow>, QueryReadFailure> {
5377 crate::query_guard::assert_read_only_query(sql)?;
5378 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
5379 let bound: Vec<SqlValue> = params
5380 .iter()
5381 .map(json_param_to_sql)
5382 .collect::<Result<_, _>>()?;
5383 let mut stmt = conn.prepare(&lowered_sql)?;
5384 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
5385 let bound_refs: Vec<&dyn rusqlite::ToSql> =
5386 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
5387 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
5388 let mut out = Vec::new();
5389 while let Some(row) = sql_rows.next()? {
5390 let mut record = Map::new();
5391 for (i, name) in column_names.iter().enumerate() {
5392 let value = row.get_ref(i)?;
5393 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
5394 }
5395 out.push(record);
5396 }
5397 Ok(out)
5398}
5399
5400fn persisted_window_state(
5401 conn: &Connection,
5402 base: &WindowBase,
5403) -> Result<WindowState, QueryReadFailure> {
5404 let mut stmt = conn.prepare(
5405 "SELECT windows.unit, subscriptions.state_json
5406 FROM _syncular_windows AS windows
5407 JOIN _syncular_subscriptions AS subscriptions
5408 ON subscriptions.id = windows.sub_id
5409 WHERE windows.base = ?1
5410 ORDER BY windows.unit ASC",
5411 )?;
5412 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
5413 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5414 })?;
5415 let mut units = Vec::new();
5416 let mut pending = Vec::new();
5417 for row in rows {
5418 let (unit, raw) = row?;
5419 let state: Value = serde_json::from_str(&raw)
5420 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
5421 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
5422 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
5423 || state
5424 .get("bootstrapState")
5425 .is_some_and(|value| !value.is_null());
5426 if is_pending {
5427 pending.push(unit.clone());
5428 }
5429 units.push(unit);
5430 }
5431 Ok(WindowState { units, pending })
5432}
5433
5434fn snapshot_connection(
5435 conn: &Connection,
5436 sql: &str,
5437 params: &[Value],
5438 coverage: &[WindowCoverage],
5439) -> Result<QuerySnapshot, QueryReadFailure> {
5440 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
5441 let result: Result<QuerySnapshot, QueryReadFailure> = (|| {
5442 let revision = conn
5443 .query_row(
5444 "SELECT value FROM _syncular_meta WHERE key = ?1",
5445 rusqlite::params![LOCAL_REVISION_KEY],
5446 |row| row.get::<_, String>(0),
5447 )
5448 .ok()
5449 .and_then(|value| value.parse::<u64>().ok())
5450 .unwrap_or(0);
5451 let rows = query_connection(conn, sql, params)?;
5452 let mut pending = Vec::new();
5453 let mut missing = Vec::new();
5454 for requested in coverage {
5455 let base_key = window_base_key(&requested.base);
5456 let state = persisted_window_state(conn, &requested.base)?;
5457 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5458 let reference = WindowUnitRef {
5459 base_key: base_key.clone(),
5460 unit: unit.clone(),
5461 };
5462 if !state.units.iter().any(|held| held == &unit) {
5463 missing.push(reference);
5464 } else if state.pending.iter().any(|held| held == &unit) {
5465 pending.push(reference);
5466 }
5467 }
5468 }
5469 Ok(QuerySnapshot {
5470 revision: revision.to_string(),
5471 rows,
5472 coverage: CoverageSnapshot {
5473 complete: pending.is_empty() && missing.is_empty(),
5474 pending,
5475 missing,
5476 },
5477 })
5478 })();
5479 match result {
5480 Ok(snapshot) => {
5481 conn.execute_batch("RELEASE syncular_snapshot_read")?;
5482 Ok(snapshot)
5483 }
5484 Err(mut error) => {
5485 if let Err(rollback) = conn
5486 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
5487 {
5488 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
5489 }
5490 Err(error)
5491 }
5492 }
5493}
5494
5495pub struct FileQuerySnapshotReader {
5500 path: String,
5501 conn: Option<Connection>,
5502}
5503
5504impl FileQuerySnapshotReader {
5505 #[must_use]
5506 pub fn new(path: impl Into<String>) -> Self {
5507 Self {
5508 path: path.into(),
5509 conn: None,
5510 }
5511 }
5512
5513 fn connection(&mut self) -> Result<&Connection, String> {
5514 if self.conn.is_none() {
5515 let conn = Connection::open_with_flags(
5516 &self.path,
5517 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5518 )
5519 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5520 conn.busy_timeout(std::time::Duration::from_millis(250))
5521 .map_err(|error| error.to_string())?;
5522 self.conn = Some(conn);
5523 }
5524 self.conn
5525 .as_ref()
5526 .ok_or_else(|| "read sidecar connection missing".to_owned())
5527 }
5528
5529 pub fn query_snapshot(
5532 &mut self,
5533 sql: &str,
5534 params: &[Value],
5535 coverage: &[WindowCoverage],
5536 ) -> Result<QuerySnapshot, QueryReadFailure> {
5537 snapshot_connection(self.connection()?, sql, params, coverage)
5538 }
5539}
5540
5541impl SyncClient {
5542 pub fn new_with_identity(
5543 client_id: Option<String>,
5544 schema_json: &Value,
5545 limits: ClientLimits,
5546 ) -> Result<Self, String> {
5547 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5548 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5549 Self::with_connection(resolved, schema_json, limits, conn)
5550 }
5551
5552 pub fn new(
5553 client_id: String,
5554 schema_json: &Value,
5555 limits: ClientLimits,
5556 ) -> Result<Self, String> {
5557 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5558 Self::with_connection(client_id, schema_json, limits, conn)
5559 }
5560
5561 pub fn open_path(
5567 client_id: String,
5568 schema_json: &Value,
5569 limits: ClientLimits,
5570 path: &str,
5571 ) -> Result<Self, String> {
5572 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5573 Self::with_connection(client_id, schema_json, limits, conn)
5574 }
5575
5576 pub fn open_path_with_identity(
5577 client_id: Option<String>,
5578 schema_json: &Value,
5579 limits: ClientLimits,
5580 path: &str,
5581 ) -> Result<Self, String> {
5582 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5583 conn.busy_timeout(std::time::Duration::from_millis(250))
5589 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5590 conn.pragma_update(None, "journal_mode", "WAL")
5591 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5592 let persisted = conn
5593 .query_row(
5594 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5595 [],
5596 |row| row.get::<_, String>(0),
5597 )
5598 .ok();
5599 let resolved = persisted
5600 .clone()
5601 .or(client_id.clone())
5602 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5603 if let (Some(existing), Some(requested)) = (persisted, client_id) {
5604 if existing != requested {
5605 return Err(format!(
5606 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5607 ));
5608 }
5609 }
5610 Self::with_connection(resolved, schema_json, limits, conn)
5611 }
5612
5613 #[must_use]
5614 pub fn client_id(&self) -> &str {
5615 &self.client_id
5616 }
5617
5618 pub fn with_connection(
5625 client_id: String,
5626 schema_json: &Value,
5627 limits: ClientLimits,
5628 conn: Connection,
5629 ) -> Result<Self, String> {
5630 if limits.outcome_retention_max_entries == Some(0) {
5631 return Err(
5632 "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5633 );
5634 }
5635 let schema = parse_schema_json(schema_json)?;
5636 let previous_version = match limits.previous_version_context {
5639 Some(config) => {
5640 config.validate()?;
5641 Some(config)
5642 }
5643 None => None,
5644 };
5645 let mut client = SyncClient {
5646 #[cfg(feature = "bench-internals")]
5647 benchmark_phases: Recorder::default(),
5648 conn,
5649 schema,
5650 client_id,
5651 limits,
5652 subs: Vec::new(),
5653 outbox: Vec::new(),
5654 failed_commits: Vec::new(),
5655 retain_failed_commits: false,
5656 conflicts: Vec::new(),
5657 rejections: Vec::new(),
5658 schema_floor: None,
5659 lease_state: None,
5660 stopped: false,
5661 upgrading: false,
5662 sync_needed: false,
5663 realtime_policy: RealtimePolicy::Optional,
5664 realtime_state: RealtimeState::Disconnected,
5665 realtime_reason_code: None,
5666 realtime_retry_delay_ms: None,
5667 presence: HashMap::new(),
5668 now_ms: None,
5669 encryption: crate::values::EncryptionConfig::default(),
5670 security_preflight: false,
5671 previous_version,
5672 insert_sql: RefCell::new(HashMap::new()),
5673 overlay_dirty: Cell::new(false),
5674 storage_failure: RefCell::new(None),
5675 #[cfg(test)]
5676 overlay_rebuild_count: Cell::new(0),
5677 #[cfg(test)]
5678 outcome_prune_count: Cell::new(0),
5679 progress: ProgressObserver::default(),
5680 change_queue: VecDeque::new(),
5681 sync_intent_queue: VecDeque::new(),
5682 retry_delay_ms: 250,
5683 round_retry_delay_ms: None,
5684 last_round: None,
5685 last_change: None,
5686 query_failures: Vec::new(),
5687 };
5688 client
5692 .conn
5693 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5694 client.create_bookkeeping_tables()?;
5698 match client.get_meta(CLIENT_ID_KEY) {
5699 Some(existing) if existing != client.client_id => {
5700 return Err(format!(
5701 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5702 client.client_id
5703 ));
5704 }
5705 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5706 _ => {}
5707 }
5708 client.restore_persisted_state()?;
5709 client.failed_commits = client.load_failed_commits()?;
5710 let marker = client
5711 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5712 .and_then(|value| value.parse::<i32>().ok());
5713 match marker {
5714 None => {
5715 client.create_synced_tables()?;
5716 client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5717 set_local_schema_descriptor(&client.conn, &client.schema);
5719 }
5720 Some(version) if version == client.schema.version => {
5721 client.create_synced_tables()?;
5722 set_local_schema_descriptor(&client.conn, &client.schema);
5726 }
5727 Some(_) => client.run_schema_reset()?,
5728 }
5729 client.reconcile_previous_version_at_boot()?;
5735 client.clear_satisfied_persisted_schema_floor();
5736 client.prune_unknown_subscriptions()?;
5737 if marker == Some(client.schema.version)
5738 && (!client.outbox.is_empty() || !client.failed_commits.is_empty())
5739 {
5740 client.overlay_dirty.set(true);
5743 client.rebuild_overlay();
5744 }
5745 client.enqueue_startup_sync_if_needed()?;
5751 Ok(client)
5752 }
5753
5754 pub fn set_now_ms(&mut self, now_ms: i64) {
5757 self.now_ms = Some(now_ms);
5758 }
5759
5760 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
5764 self.encryption = encryption;
5765 }
5766
5767 #[must_use]
5768 pub fn security_lifecycle(&self) -> &'static str {
5769 if self.security_preflight {
5770 "preflight"
5771 } else {
5772 "active"
5773 }
5774 }
5775
5776 #[must_use]
5777 pub fn security_preflight(&self) -> bool {
5778 self.security_preflight
5779 }
5780
5781 pub fn begin_security_preflight(&mut self) {
5788 self.seal_security_on_teardown();
5789 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
5792 }
5793
5794 pub fn seal_security_on_teardown(&mut self) {
5804 self.security_preflight = true;
5805 self.encryption = crate::values::EncryptionConfig::default();
5806 self.sync_intent_queue.clear();
5807 }
5808
5809 pub fn activate_security(
5811 &mut self,
5812 encryption: crate::values::EncryptionConfig,
5813 ) -> Result<(), String> {
5814 if !self.security_preflight {
5815 return Err(
5816 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
5817 );
5818 }
5819 self.enqueue_startup_sync_if_needed()?;
5820 self.encryption = encryption;
5821 self.security_preflight = false;
5822 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
5823 Ok(())
5824 }
5825
5826 fn clock_now_ms(&self) -> i64 {
5827 self.now_ms.unwrap_or_else(|| {
5828 std::time::SystemTime::now()
5829 .duration_since(std::time::UNIX_EPOCH)
5830 .map(|d| d.as_millis() as i64)
5831 .unwrap_or(0)
5832 })
5833 }
5834
5835 fn create_bookkeeping_tables(&self) -> Result<(), String> {
5836 self.conn
5838 .execute_batch(
5839 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
5840 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5841 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
5842 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
5843 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
5844 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
5845 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
5846 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5847 client_commit_id TEXT NOT NULL UNIQUE,
5848 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
5849 recorded_at_ms INTEGER NOT NULL,
5850 results_json TEXT NOT NULL,
5851 operations_json TEXT,
5852 resolution TEXT NOT NULL DEFAULT 'active'
5853 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
5854 resolved_at_ms INTEGER,
5855 replacement_client_commit_id TEXT);
5856 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
5857 ON _syncular_commit_outcomes(resolution, seq);
5858 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
5859 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
5860 CREATE TABLE IF NOT EXISTS _syncular_meta (
5861 key TEXT PRIMARY KEY, value TEXT NOT NULL);
5862 CREATE TABLE IF NOT EXISTS _syncular_windows (
5863 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
5864 PRIMARY KEY (base, unit));
5865 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
5866 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
5867 effective_scopes TEXT NOT NULL);
5868 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
5869 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
5870 PRIMARY KEY(commit_id, blob_id));
5871 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
5872 ON _syncular_blob_commit_refs(blob_id);",
5873 )
5874 .map_err(|e| e.to_string())?;
5875 let _ = self
5878 .conn
5879 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
5880 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
5881 self.set_meta(LOCAL_REVISION_KEY, "0");
5882 }
5883 if self.schema_has_blobs() {
5886 let exists = self
5887 .conn
5888 .query_row(
5889 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5890 [],
5891 |row| row.get::<_, bool>(0),
5892 )
5893 .map_err(|e| e.to_string())?;
5894 if exists {
5895 let mut statement = self
5896 .conn
5897 .prepare("PRAGMA table_info(_syncular_blobs)")
5898 .map_err(|e| e.to_string())?;
5899 let mut columns = statement
5900 .query_map([], |row| row.get::<_, String>(1))
5901 .map_err(|e| e.to_string())?
5902 .collect::<Result<Vec<_>, _>>()
5903 .map_err(|e| e.to_string())?;
5904 columns.sort();
5905 let mut expected = vec![
5906 "blob_id",
5907 "byte_length",
5908 "bytes",
5909 "created_at_ms",
5910 "media_type",
5911 ];
5912 expected.sort_unstable();
5913 if columns != expected {
5914 return Err(
5915 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5916 );
5917 }
5918 } else {
5919 self.conn
5920 .execute_batch(
5921 "CREATE TABLE _syncular_blobs(
5922 blob_id TEXT PRIMARY KEY,
5923 bytes BLOB NOT NULL,
5924 byte_length INTEGER NOT NULL,
5925 media_type TEXT,
5926 created_at_ms INTEGER NOT NULL);",
5927 )
5928 .map_err(|e| e.to_string())?;
5929 }
5930 self.conn
5931 .execute_batch(
5932 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5933 blob_id TEXT PRIMARY KEY,
5934 media_type TEXT,
5935 created_at_ms INTEGER NOT NULL);",
5936 )
5937 .map_err(|e| e.to_string())?;
5938 }
5939 Ok(())
5940 }
5941
5942 fn schema_has_blobs(&self) -> bool {
5944 self.schema
5945 .tables
5946 .iter()
5947 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5948 }
5949
5950 fn get_meta(&self, key: &str) -> Option<String> {
5953 meta_get(&self.conn, key)
5954 }
5955
5956 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5957 self.conn
5958 .query_row(
5959 "SELECT value FROM _syncular_meta WHERE key = ?1",
5960 rusqlite::params![key],
5961 |row| row.get::<_, String>(0),
5962 )
5963 .optional()
5964 .map_err(|_| {
5965 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5966 })
5967 }
5968
5969 fn set_meta(&self, key: &str, value: &str) {
5970 meta_set(&self.conn, key, value);
5971 }
5972
5973 fn delete_meta(&self, key: &str) {
5974 meta_delete(&self.conn, key);
5975 }
5976
5977 fn restore_persisted_state(&mut self) -> Result<(), String> {
5978 self.subs = {
5979 let mut stmt = self
5980 .conn
5981 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5982 .map_err(|error| error.to_string())?;
5983 let rows = stmt
5984 .query_map([], |row| {
5985 Ok((
5986 row.get::<_, String>(0)?,
5987 row.get::<_, String>(1)?,
5988 row.get::<_, String>(2)?,
5989 ))
5990 })
5991 .map_err(|error| error.to_string())?;
5992 let mut subscriptions = Vec::new();
5993 for row in rows {
5994 let (id, table, raw) = row.map_err(|error| error.to_string())?;
5995 let state: Value = serde_json::from_str(&raw)
5996 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5997 let requested = json_to_scope_map(
5998 state.get("requested").unwrap_or(&Value::Object(Map::new())),
5999 )?;
6000 let effective = state
6001 .get("effectiveScopes")
6002 .filter(|value| !value.is_null())
6003 .map(json_to_scope_map)
6004 .transpose()?;
6005 subscriptions.push(Subscription {
6006 id,
6007 table,
6008 requested,
6009 params: state
6010 .get("params")
6011 .and_then(Value::as_str)
6012 .map(str::to_owned),
6013 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
6014 bootstrap_state: state
6015 .get("bootstrapState")
6016 .and_then(Value::as_str)
6017 .map(str::to_owned),
6018 state: SubState::parse(
6019 state
6020 .get("status")
6021 .and_then(Value::as_str)
6022 .unwrap_or("active"),
6023 ),
6024 reason_code: state
6025 .get("reasonCode")
6026 .and_then(Value::as_str)
6027 .map(str::to_owned),
6028 effective,
6029 synced_once: state
6030 .get("syncedOnce")
6031 .and_then(Value::as_bool)
6032 .unwrap_or(false),
6033 });
6034 }
6035 subscriptions
6036 };
6037
6038 self.outbox = {
6039 let mut stmt = self
6040 .conn
6041 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
6042 .map_err(|error| error.to_string())?;
6043 let rows = stmt
6044 .query_map([], |row| {
6045 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6046 })
6047 .map_err(|error| error.to_string())?;
6048 let mut commits = Vec::new();
6049 for row in rows {
6050 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
6051 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
6052 format!("invalid persisted outbox {client_commit_id:?}: {error}")
6053 })?;
6054 let mut ops = Vec::with_capacity(entries.len());
6055 for entry in entries {
6056 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
6057 ops.push(OutboxOp {
6058 upsert: op == "upsert",
6059 table: entry
6060 .get("table")
6061 .and_then(Value::as_str)
6062 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
6063 .to_owned(),
6064 row_id: entry
6065 .get("rowId")
6066 .and_then(Value::as_str)
6067 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
6068 .to_owned(),
6069 base_version: entry.get("baseVersion").and_then(Value::as_i64),
6070 values: entry.get("values").and_then(Value::as_object).cloned(),
6073 });
6074 }
6075 commits.push(OutboxCommit {
6076 client_commit_id,
6077 ops,
6078 });
6079 }
6080 commits
6081 };
6082
6083 self.prune_commit_outcomes()?;
6084 let active = self.commit_outcomes(CommitOutcomeQuery {
6085 active_only: true,
6086 ..CommitOutcomeQuery::default()
6087 })?;
6088 self.conflicts = active
6089 .iter()
6090 .flat_map(|outcome| outcome.results.iter())
6091 .filter_map(|result| match result {
6092 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
6093 _ => None,
6094 })
6095 .collect();
6096 self.rejections = active
6097 .iter()
6098 .flat_map(|outcome| outcome.results.iter())
6099 .filter_map(|result| match result {
6100 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
6101 _ => None,
6102 })
6103 .collect();
6104
6105 self.lease_state = self
6106 .get_meta(LEASE_STATE_KEY)
6107 .map(|raw| serde_json::from_str(&raw))
6108 .transpose()
6109 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
6110 self.schema_floor = self
6111 .get_meta(SCHEMA_FLOOR_KEY)
6112 .map(|raw| serde_json::from_str(&raw))
6113 .transpose()
6114 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
6115 self.stopped = self.schema_floor.is_some();
6116 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
6119 self.security_preflight = true;
6120 }
6121 Ok(())
6122 }
6123
6124 fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
6127 let valid_tables: BTreeSet<String> = self
6128 .schema
6129 .tables
6130 .iter()
6131 .map(|table| table.name.clone())
6132 .collect();
6133 let stale_ids: Vec<String> = self
6134 .subs
6135 .iter()
6136 .filter(|sub| !valid_tables.contains(&sub.table))
6137 .map(|sub| sub.id.clone())
6138 .collect();
6139 for id in &stale_ids {
6140 self.conn
6141 .execute(
6142 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
6143 rusqlite::params![id],
6144 )
6145 .map_err(|error| error.to_string())?;
6146 self.conn
6147 .execute(
6148 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
6149 rusqlite::params![id],
6150 )
6151 .map_err(|error| error.to_string())?;
6152 self.conn
6153 .execute(
6154 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6155 rusqlite::params![id],
6156 )
6157 .map_err(|error| error.to_string())?;
6158 }
6159 self.subs.retain(|sub| valid_tables.contains(&sub.table));
6160 Ok(())
6161 }
6162
6163 #[must_use]
6164 pub fn local_revision(&self) -> u64 {
6165 self.get_meta(LOCAL_REVISION_KEY)
6166 .and_then(|value| value.parse().ok())
6167 .unwrap_or(0)
6168 }
6169
6170 #[must_use]
6171 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
6172 SyncStatusSnapshot {
6173 current_schema_version: self.schema.version,
6174 outbox: self.outbox.len(),
6175 upgrading: self.upgrading,
6176 lease_state: self.lease_state.clone(),
6177 schema_floor: self.schema_floor.clone(),
6178 sync_needed: self.sync_needed,
6179 previous_version_context: self.previous_version_status(),
6180 }
6181 }
6182
6183 fn previous_version_status(&self) -> PreviousVersionStatus {
6186 let Some(replica_path) = self.previous_version_replica_path() else {
6187 return PreviousVersionStatus {
6188 present: false,
6189 created_at_ms: None,
6190 };
6191 };
6192 match crate::previous_version::read_previous_version_record(&replica_path) {
6193 Ok(Some(record)) => PreviousVersionStatus {
6194 present: true,
6195 created_at_ms: Some(record.created_at_ms),
6196 },
6197 _ => PreviousVersionStatus {
6198 present: std::path::Path::new(
6199 &crate::previous_version::previous_version_container_path(&replica_path),
6200 )
6201 .exists(),
6202 created_at_ms: None,
6203 },
6204 }
6205 }
6206
6207 pub fn previous_version_snapshot(
6213 &self,
6214 spec: &PreviousVersionReadSpec,
6215 ) -> Result<PreviousVersionSnapshot, String> {
6216 let now_ms = self.clock_now_ms();
6217 crate::previous_version::previous_version_snapshot(
6218 &self.conn,
6219 self.previous_version_replica_path().as_deref(),
6220 self.previous_version.as_ref(),
6221 PreviousVersionLifecycle {
6222 lease_inactive: self.previous_version_lease_inactive(now_ms),
6223 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
6224 coverage_complete: self.previous_version_coverage_complete(),
6225 },
6226 now_ms,
6227 self.schema.version,
6228 spec,
6229 )
6230 }
6231
6232 #[must_use]
6234 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
6235 crate::previous_version::stored_previous_version_audit(&self.conn)
6236 }
6237
6238 pub fn previous_version_discard(
6242 &self,
6243 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
6244 self.drop_previous_version()
6245 }
6246
6247 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
6251 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
6252 present: false,
6253 discarded: false,
6254 };
6255 let Some(replica_path) = self.previous_version_replica_path() else {
6256 return absent;
6257 };
6258 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
6259 .unwrap_or(absent)
6260 }
6261
6262 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
6264 let Some(lease) = &self.lease_state else {
6265 return false;
6266 };
6267 if lease.error_code.is_some() {
6268 return true;
6269 }
6270 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
6271 }
6272
6273 fn previous_version_coverage_complete(&self) -> bool {
6277 let active = self
6278 .subs
6279 .iter()
6280 .filter(|sub| sub.state == SubState::Active)
6281 .collect::<Vec<_>>();
6282 if active.is_empty() {
6283 return false;
6284 }
6285 active
6286 .iter()
6287 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
6288 }
6289
6290 fn previous_version_lifetime_check(&self) {
6294 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
6295 return;
6296 };
6297 let now_ms = self.clock_now_ms();
6298 if self.previous_version_lease_inactive(now_ms) {
6299 let _ = self.drop_previous_version();
6300 return;
6301 }
6302 let Some(replica_path) = self.previous_version_replica_path() else {
6303 return;
6304 };
6305 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
6306 else {
6307 return;
6308 };
6309 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
6310 let _ = self.drop_previous_version();
6311 return;
6312 }
6313 if now_ms - record.created_at_ms > config.max_age_ms {
6314 let _ = self.drop_previous_version();
6315 return;
6316 }
6317 if self.previous_version_coverage_complete() {
6318 let _ = self.drop_previous_version();
6319 }
6320 }
6321
6322 pub fn diagnostics_snapshot(
6323 &self,
6324 request: &ClientDiagnosticsRequest,
6325 ) -> Result<ClientDiagnosticsSnapshot, String> {
6326 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
6327 return Err(format!(
6328 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
6329 ));
6330 }
6331 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
6332 for sub in &self.subs {
6333 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
6334 let complete =
6335 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
6336 let state = match sub.state {
6337 SubState::Revoked => "revoked",
6338 SubState::Failed => "failed",
6339 SubState::Active if reset => "reset",
6340 SubState::Active if complete => "complete",
6341 SubState::Active => "bootstrapping",
6342 };
6343 subscriptions.insert(
6344 sub.id.clone(),
6345 DiagnosticSubscription {
6346 id: sub.id.clone(),
6347 table: sub.table.clone(),
6348 state: state.to_owned(),
6349 complete,
6350 cursor: Some(sub.cursor),
6351 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
6352 },
6353 );
6354 }
6355 for expected in &request.expected_subscriptions {
6356 if expected.id.is_empty() || expected.table.is_empty() {
6357 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
6358 }
6359 if subscriptions
6360 .get(&expected.id)
6361 .is_some_and(|registered| registered.table != expected.table)
6362 {
6363 subscriptions.insert(
6364 expected.id.clone(),
6365 DiagnosticSubscription {
6366 id: expected.id.clone(),
6367 table: expected.table.clone(),
6368 state: "failed".to_owned(),
6369 complete: false,
6370 cursor: None,
6371 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
6372 },
6373 );
6374 } else {
6375 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
6376 DiagnosticSubscription {
6377 id: expected.id.clone(),
6378 table: expected.table.clone(),
6379 state: "unregistered".to_owned(),
6380 complete: false,
6381 cursor: None,
6382 reason_code: None,
6383 }
6384 });
6385 }
6386 }
6387 let mut ordered_subscriptions = Vec::new();
6388 let mut included = BTreeSet::new();
6389 for expected in &request.expected_subscriptions {
6390 if included.insert(expected.id.clone()) {
6391 if let Some(subscription) = subscriptions.get(&expected.id) {
6392 ordered_subscriptions.push(subscription.clone());
6393 }
6394 }
6395 }
6396 for (id, subscription) in &subscriptions {
6397 if included.insert(id.clone()) {
6398 ordered_subscriptions.push(subscription.clone());
6399 }
6400 }
6401 let subscriptions_truncated =
6402 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
6403 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
6404 let captured_at_ms = self.clock_now_ms();
6405 let lease = if let Some(error_code) = self
6406 .lease_state
6407 .as_ref()
6408 .and_then(|state| state.error_code.clone())
6409 {
6410 ClientDiagnosticsLease {
6411 state: "stopped".to_owned(),
6412 expires_at_ms: self
6413 .lease_state
6414 .as_ref()
6415 .and_then(|state| state.expires_at_ms),
6416 error_code: Some(Self::diagnostic_code(&error_code)),
6417 }
6418 } else if let Some(expires_at_ms) = self
6419 .lease_state
6420 .as_ref()
6421 .and_then(|state| state.expires_at_ms)
6422 {
6423 ClientDiagnosticsLease {
6424 state: if expires_at_ms <= captured_at_ms {
6425 "expired".to_owned()
6426 } else {
6427 "active".to_owned()
6428 },
6429 expires_at_ms: Some(expires_at_ms),
6430 error_code: None,
6431 }
6432 } else {
6433 ClientDiagnosticsLease {
6434 state: "none".to_owned(),
6435 expires_at_ms: None,
6436 error_code: None,
6437 }
6438 };
6439 let connectivity = match self.last_round.as_ref() {
6440 Some(round) if round.status == "succeeded" => "online",
6441 Some(round)
6442 if round.status == "failed"
6443 && round
6444 .error_code
6445 .as_deref()
6446 .is_some_and(Self::retryable_transport_code) =>
6447 {
6448 "offline"
6449 }
6450 _ => "unknown",
6451 };
6452 Ok(ClientDiagnosticsSnapshot {
6453 version: CLIENT_DIAGNOSTICS_VERSION,
6454 captured_at_ms,
6455 host: ClientDiagnosticsHost {
6456 kind: "direct".to_owned(),
6457 role: "single".to_owned(),
6458 connectivity: connectivity.to_owned(),
6459 realtime: self.realtime_state,
6460 realtime_policy: self.realtime_policy,
6461 realtime_reason_code: self.realtime_reason_code.clone(),
6462 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
6463 },
6464 security_lifecycle: self.security_lifecycle().to_owned(),
6465 schema: ClientDiagnosticsSchema {
6466 current_version: self.schema.version,
6467 upgrading: self.upgrading,
6468 required_version: self
6469 .schema_floor
6470 .as_ref()
6471 .and_then(|floor| floor.required_schema_version),
6472 latest_version: self
6473 .schema_floor
6474 .as_ref()
6475 .and_then(|floor| floor.latest_schema_version),
6476 },
6477 replica: ClientDiagnosticsReplica {
6478 local_revision: self.local_revision().to_string(),
6479 sync_needed: self.sync_needed,
6480 pending_outbox: self.outbox.len(),
6481 },
6482 lease,
6483 subscriptions: ordered_subscriptions,
6484 subscriptions_truncated,
6485 last_round: self.last_round.clone(),
6486 last_change: self.last_change.clone(),
6487 storage: self.diagnostics_storage(),
6488 query_failures: self.query_failures.clone(),
6489 })
6490 }
6491
6492 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
6493 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
6494 let page_count: i64 = self
6495 .conn
6496 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
6497 let page_size: i64 = self
6498 .conn
6499 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
6500 let outbox_bytes: i64 = self
6502 .conn
6503 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
6504 .query_row([], |row| row.get(0))?;
6505 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
6506 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
6507 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
6508 let blob_bytes = if self.schema_has_blobs() {
6509 self.conn
6510 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
6511 .query_row([], |row| row.get(0))?
6512 } else {
6513 0
6514 };
6515 let pressure = self
6516 .limits
6517 .blob_cache_max_bytes
6518 .is_some_and(|limit| blob_bytes > limit);
6519 Ok(ClientDiagnosticsStorage {
6520 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
6521 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
6522 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
6523 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
6524 retained_outcome_entries: Some(outcome_entries.max(0)),
6525 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
6526 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
6527 })
6528 };
6529 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
6530 status: "unreadable".to_owned(),
6531 database_bytes_approx: None,
6532 pending_outbox_bytes_approx: None,
6533 retained_outcome_bytes_approx: None,
6534 retained_outcome_entries: None,
6535 blob_cache_bytes_approx: None,
6536 pressure_reason_code: None,
6537 })
6538 }
6539
6540 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
6541 self.change_queue.drain(..).collect()
6542 }
6543
6544 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
6545 self.sync_intent_queue.drain(..).collect()
6546 }
6547
6548 fn schedule_background_retry(&mut self) -> u64 {
6549 let delay_ms = self.retry_delay_ms;
6550 self.sync_intent_queue
6551 .push_back(SyncIntent::Background { delay_ms });
6552 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
6553 self.round_retry_delay_ms = Some(delay_ms);
6554 delay_ms
6555 }
6556
6557 fn reset_background_retry(&mut self) {
6558 self.retry_delay_ms = 250;
6559 }
6560
6561 fn retryable_transport_code(code: &str) -> bool {
6562 code == "transport.failed"
6563 || code == "transport.unavailable"
6564 || code == "sync.transport_failed"
6565 }
6566
6567 fn retryable_failure_code(code: &str) -> bool {
6572 Self::retryable_transport_code(code)
6573 || matches!(
6574 code,
6575 "sync.auth_required"
6576 | "sync.auth_lease_required"
6577 | "sync.auth_lease_revoked"
6578 | "sync.internal_error"
6579 | "sync.idempotency_cache_miss"
6580 | "sync.schema_not_ready"
6581 | "sync.segment_expired"
6582 | "sync.rate_limited"
6583 | "sync.websocket_connection_limit"
6584 )
6585 }
6586
6587 fn diagnostic_code(code: &str) -> String {
6588 let valid = !code.is_empty()
6589 && code.len() <= 96
6590 && code.contains('.')
6591 && code
6592 .bytes()
6593 .next()
6594 .is_some_and(|byte| byte.is_ascii_lowercase())
6595 && code.bytes().all(|byte| {
6596 byte.is_ascii_lowercase()
6597 || byte.is_ascii_digit()
6598 || matches!(byte, b'.' | b'_' | b'-')
6599 });
6600 if valid {
6601 code.to_owned()
6602 } else {
6603 "client.unknown_failure".to_owned()
6604 }
6605 }
6606
6607 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
6608 if self.sync_needed != value {
6609 if self.begin_observation("syncular_status").is_ok() {
6610 self.sync_needed = value;
6611 let batch = ChangeAccumulator {
6612 status: true,
6613 ..ChangeAccumulator::default()
6614 };
6615 if self.finish_observation("syncular_status", batch).is_err() {
6616 self.rollback_observation("syncular_status");
6617 }
6618 } else {
6619 self.sync_needed = value;
6620 }
6621 }
6622 if value && interactive {
6623 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6624 }
6625 }
6626
6627 fn sqlite_failure(
6628 storage_failure: &RefCell<Option<QueryReadFailure>>,
6629 error: rusqlite::Error,
6630 ) -> String {
6631 let message = error.to_string();
6632 let failure = QueryReadFailure::from(error);
6633 let code = failure.code;
6634 if code.is_some() && storage_failure.borrow().is_none() {
6635 *storage_failure.borrow_mut() = Some(failure);
6636 }
6637 match code {
6638 Some(code) => format!("{code}: {message}"),
6639 None => message,
6640 }
6641 }
6642
6643 fn begin_observation(&self, name: &str) -> Result<(), String> {
6644 self.conn
6645 .execute_batch(&format!("SAVEPOINT {name}"))
6646 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
6647 }
6648
6649 fn rollback_observation(&self, name: &str) {
6650 if let Err(error) = self
6651 .conn
6652 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
6653 {
6654 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
6655 if failure.rollback_failure.is_none() {
6656 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
6657 }
6658 }
6659 }
6660 }
6661
6662 fn finish_observation(
6663 &mut self,
6664 name: &str,
6665 mut batch: ChangeAccumulator,
6666 ) -> Result<(), String> {
6667 #[cfg(feature = "bench-internals")]
6668 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
6669 if self.failed_commits.iter().any(|(commit, _)| {
6670 commit
6671 .ops
6672 .iter()
6673 .any(|operation| batch.tables.contains_key(&operation.table))
6674 }) {
6675 batch.outcomes = true;
6676 }
6677 if !batch.touched() {
6678 self.conn
6679 .execute_batch(&format!("RELEASE {name}"))
6680 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6681 return Ok(());
6682 }
6683 let revision = self
6684 .local_revision()
6685 .checked_add(1)
6686 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
6687 self.conn
6688 .execute(
6689 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
6690 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
6691 )
6692 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6693 let status = batch.status.then(|| self.status_snapshot());
6694 let event = ClientChangeBatch {
6695 revision: revision.to_string(),
6696 tables: batch
6697 .tables
6698 .into_iter()
6699 .map(|(table, scope_keys)| TableChange {
6700 table,
6701 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
6702 })
6703 .collect(),
6704 windows: batch
6705 .windows
6706 .into_iter()
6707 .map(|((base_key, table), units)| WindowChange {
6708 base_key,
6709 table,
6710 units: units.into_iter().collect(),
6711 })
6712 .collect(),
6713 status,
6714 conflicts_changed: batch.conflicts,
6715 rejections_changed: batch.rejections,
6716 outcomes_changed: batch.outcomes,
6717 };
6718 self.conn
6719 .execute_batch(&format!("RELEASE {name}"))
6720 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6721 let mut diagnostic_tables = event
6722 .tables
6723 .iter()
6724 .map(|entry| entry.table.clone())
6725 .collect::<BTreeSet<_>>()
6726 .into_iter()
6727 .collect::<Vec<_>>();
6728 let mut diagnostic_windows = event
6729 .windows
6730 .iter()
6731 .map(|entry| entry.table.clone())
6732 .collect::<BTreeSet<_>>()
6733 .into_iter()
6734 .collect::<Vec<_>>();
6735 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
6736 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
6737 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
6738 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
6739 self.last_change = Some(DiagnosticLastChange {
6740 revision: event.revision.clone(),
6741 recorded_at_ms: self.clock_now_ms(),
6742 tables: diagnostic_tables,
6743 windows: diagnostic_windows,
6744 domains_truncated,
6745 status_changed: event.status.is_some(),
6746 conflicts_changed: event.conflicts_changed,
6747 rejections_changed: event.rejections_changed,
6748 outcomes_changed: event.outcomes_changed,
6749 });
6750 self.change_queue.push_back(event);
6751 Ok(())
6752 }
6753
6754 fn record_scope_map(
6755 &self,
6756 batch: &mut ChangeAccumulator,
6757 table_name: &str,
6758 scopes: &[(String, Vec<String>)],
6759 ) {
6760 let Some(table) = self.schema.table(table_name) else {
6761 return;
6762 };
6763 for (variable, values) in scopes {
6764 let Some(scope) = table
6765 .scope_variables
6766 .iter()
6767 .find(|scope| &scope.variable == variable)
6768 else {
6769 continue;
6770 };
6771 for value in values {
6772 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6773 }
6774 }
6775 }
6776
6777 fn record_row_scopes(
6779 &self,
6780 batch: &mut ChangeAccumulator,
6781 table_name: &str,
6782 row_id: &str,
6783 base: bool,
6784 ) -> bool {
6785 let Some(table) = self.schema.table(table_name) else {
6786 return false;
6787 };
6788 if table.scope_variables.is_empty() {
6789 return false;
6790 }
6791 let columns = table
6792 .scope_variables
6793 .iter()
6794 .map(|scope| quote_ident(&scope.column))
6795 .collect::<Vec<_>>()
6796 .join(", ");
6797 let full_table = if base {
6798 base_table(table_name)
6799 } else {
6800 visible_table(table_name)
6801 };
6802 let sql = format!(
6803 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
6804 row_id_predicate(table)
6805 );
6806 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
6807 return false;
6808 };
6809 let values = stmt.query_row(rusqlite::params![row_id], |row| {
6810 let mut values = Vec::with_capacity(table.scope_variables.len());
6811 for index in 0..table.scope_variables.len() {
6812 values.push(row.get::<_, Option<String>>(index)?);
6813 }
6814 Ok(values)
6815 });
6816 let Ok(values) = values else {
6817 return false;
6818 };
6819 let mut recorded = false;
6820 for (scope, value) in table.scope_variables.iter().zip(values) {
6821 if let Some(value) = value {
6822 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6823 recorded = true;
6824 }
6825 }
6826 recorded
6827 }
6828
6829 fn record_commit_changes(
6830 &self,
6831 batch: &mut ChangeAccumulator,
6832 tables: &[String],
6833 changes: &[ssp2::model::Change],
6834 ) {
6835 #[cfg(feature = "bench-internals")]
6836 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
6837 for change in changes {
6838 let Some(table_name) = tables.get(change.table_index as usize) else {
6839 continue;
6840 };
6841 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
6842 if let Some(table) = self.schema.table(table_name) {
6843 for (variable, value) in &change.scopes {
6844 if let Some(scope) = table
6845 .scope_variables
6846 .iter()
6847 .find(|scope| &scope.variable == variable)
6848 {
6849 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6850 precise = true;
6851 }
6852 }
6853 }
6854 if !precise {
6855 batch.table(table_name);
6856 }
6857 }
6858 }
6859
6860 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
6861 if effective.is_empty() {
6862 return false;
6863 }
6864 let Some(table) = self.schema.table(table_name) else {
6865 return false;
6866 };
6867 let mut clauses = Vec::new();
6868 let mut params = Vec::new();
6869 for (variable, values) in effective {
6870 let Some(column) = table.scope_column(variable) else {
6871 return false;
6872 };
6873 if values.is_empty() {
6874 return false;
6875 }
6876 let placeholders = values
6877 .iter()
6878 .map(|value| {
6879 params.push(SqlValue::Text(value.clone()));
6880 "?"
6881 })
6882 .collect::<Vec<_>>()
6883 .join(", ");
6884 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
6885 }
6886 let sql = format!(
6887 "SELECT 1 FROM {} WHERE {} LIMIT 1",
6888 base_table(table_name),
6889 clauses.join(" AND ")
6890 );
6891 self.conn
6892 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
6893 .is_ok()
6894 }
6895
6896 pub fn upgrading(&self) -> bool {
6898 self.upgrading
6899 }
6900
6901 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
6907 let new_schema = parse_schema_json(schema_json)?;
6908 let marker: Option<i32> = self
6909 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6910 .and_then(|v| v.parse().ok());
6911 self.schema = new_schema;
6912 self.create_bookkeeping_tables()?;
6913 if marker != Some(self.schema.version) {
6914 self.run_schema_reset()?;
6915 }
6916 self.prune_unknown_subscriptions()?;
6917 self.enqueue_startup_sync_if_needed()?;
6921 Ok(())
6922 }
6923
6924 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
6925 let startup_work = !self.stopped
6926 && (!self.outbox.is_empty()
6927 || !self.load_pending_evictions()?.is_empty()
6928 || self.subs.iter().any(|sub| sub.state == SubState::Active));
6929 if startup_work {
6930 self.sync_needed = true;
6931 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6932 }
6933 Ok(())
6934 }
6935
6936 fn clear_satisfied_persisted_schema_floor(&mut self) {
6943 let satisfied = self
6944 .schema_floor
6945 .as_ref()
6946 .and_then(|floor| floor.required_schema_version)
6947 .is_some_and(|required| self.schema.version >= required);
6948 if !satisfied {
6949 return;
6950 }
6951 self.schema_floor = None;
6952 self.stopped = false;
6953 self.delete_meta(SCHEMA_FLOOR_KEY);
6954 }
6955
6956 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
6961 let Some(replica_path) = self.previous_version_replica_path() else {
6962 return Ok(());
6963 };
6964 let max_age_ms = self
6965 .previous_version
6966 .filter(|config| config.enabled)
6967 .map(|config| config.max_age_ms);
6968 reconcile_previous_version_at_boot(
6969 &self.conn,
6970 &replica_path,
6971 self.schema.version,
6972 max_age_ms,
6973 self.clock_now_ms(),
6974 )
6975 }
6976
6977 fn previous_version_replica_path(&self) -> Option<String> {
6981 let path = self.conn.path()?;
6982 if path.is_empty() || path.starts_with("file:") {
6983 return None;
6984 }
6985 Some(path.to_owned())
6986 }
6987
6988 fn run_schema_reset(&mut self) -> Result<(), String> {
6994 self.begin_observation("syncular_schema_reset")?;
6995 let mut batch = ChangeAccumulator::default();
6996 let result = self.run_schema_reset_observed(&mut batch, true, true);
6997 if let Err(error) = result {
6998 self.rollback_observation("syncular_schema_reset");
6999 return Err(error);
7000 }
7001 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
7002 self.rollback_observation("syncular_schema_reset");
7003 return Err(error);
7004 }
7005 Ok(())
7006 }
7007
7008 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
7009 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
7010 self.begin_observation("syncular_log_epoch_reset")?;
7011 let mut batch = ChangeAccumulator::default();
7012 let result = self.run_schema_reset_observed(&mut batch, false, false);
7013 if let Err(error) = result {
7014 self.rollback_observation("syncular_log_epoch_reset");
7015 return Err(error);
7016 }
7017 self.set_meta(LOG_EPOCH_KEY, log_epoch);
7018 self.sync_needed = true;
7019 batch.status = true;
7020 if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
7021 self.rollback_observation("syncular_log_epoch_reset");
7022 return Err(error);
7023 }
7024 self.sync_intent_queue.push_back(SyncIntent::Interactive);
7025 Ok(resets)
7026 }
7027
7028 fn run_schema_reset_observed(
7029 &mut self,
7030 batch: &mut ChangeAccumulator,
7031 drop_incompatible: bool,
7032 capture: bool,
7033 ) -> Result<(), String> {
7034 self.upgrading = true;
7035 batch.status = true;
7036 if self.previous_version.is_some_and(|config| config.enabled) {
7042 let previous_version = self
7043 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7044 .and_then(|value| value.parse::<i32>().ok())
7045 .unwrap_or(self.schema.version);
7046 let pending = self
7047 .outbox
7048 .iter()
7049 .map(|commit| PendingCommitAudit {
7050 commit_id: &commit.client_commit_id,
7051 operations: commit_audit_operations(commit),
7052 })
7053 .collect::<Vec<_>>();
7054 crate::previous_version::write_previous_version_audit(
7055 &self.conn,
7056 &crate::previous_version::build_previous_version_audit(
7057 &self.schema,
7058 &pending,
7059 previous_version,
7060 self.schema.version,
7061 self.clock_now_ms(),
7062 ),
7063 );
7064 }
7065 if let Some(replica_path) = self.previous_version_replica_path() {
7070 sweep_previous_version_container(&self.conn, &replica_path)?;
7071 if capture {
7072 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
7073 let previous_version = self
7074 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7075 .and_then(|value| value.parse::<i32>().ok())
7076 .unwrap_or(self.schema.version);
7077 capture_previous_version_from_replica(
7078 &self.conn,
7079 &replica_path,
7080 previous_version,
7081 self.schema.version,
7082 &config,
7083 self.clock_now_ms(),
7084 )?;
7085 }
7086 }
7087 }
7088 for table in &self.schema.tables {
7089 batch.table(&table.name);
7090 }
7091 for (base_key, unit, table) in self.load_registered_window_units() {
7092 batch.window(&base_key, &table, &unit);
7093 }
7094 self.insert_sql.borrow_mut().clear();
7096 self.overlay_dirty.set(true);
7097 let virtual_tables: Vec<String> = {
7105 let mut stmt = self
7106 .conn
7107 .prepare(
7108 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
7109 )
7110 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7111 let rows = stmt
7112 .query_map([], |row| row.get::<_, String>(0))
7113 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7114 rows.filter_map(Result::ok)
7115 .filter(|name| is_synced_table_name(name))
7116 .collect()
7117 };
7118 for name in virtual_tables {
7119 self.conn
7120 .execute_batch(&format!(
7121 "DROP TABLE IF EXISTS {}",
7122 quote_ident(&format!("_syncular_fts_{name}"))
7123 ))
7124 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7125 self.conn
7126 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7127 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7128 }
7129 let existing: Vec<String> = {
7130 let mut stmt = self
7131 .conn
7132 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
7133 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7134 let rows = stmt
7135 .query_map([], |row| row.get::<_, String>(0))
7136 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7137 rows.filter_map(Result::ok)
7138 .filter(|name| is_synced_table_name(name))
7139 .collect()
7140 };
7141 for name in &existing {
7142 if let Some(table) = name.strip_prefix("_syncular_base_") {
7143 batch.table(table);
7144 } else if !name.starts_with("_syncular_") {
7145 batch.table(name);
7146 }
7147 }
7148 for name in existing {
7149 self.conn
7150 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7151 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7152 }
7153 self.create_synced_tables()?;
7155 for sub in &mut self.subs {
7157 sub.cursor = -1;
7158 sub.bootstrap_state = None;
7159 sub.effective = None;
7160 sub.state = SubState::Active;
7161 sub.reason_code = None;
7162 sub.synced_once = false;
7163 }
7164 let subs = self.subs.clone();
7165 for sub in &subs {
7166 self.persist_sub(sub)?;
7167 }
7168 self.stopped = false;
7170 self.schema_floor = None;
7171 self.delete_meta(SCHEMA_FLOOR_KEY);
7172 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
7174 set_local_schema_descriptor(&self.conn, &self.schema);
7177 if drop_incompatible && self.drop_incompatible_outbox()? {
7181 batch.rejections = true;
7182 batch.status = true;
7183 batch.outcomes = true;
7184 }
7185 self.rebuild_overlay();
7187 Ok(())
7188 }
7189
7190 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
7194 let incompatible = self
7195 .outbox
7196 .iter()
7197 .filter(|commit| {
7198 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
7199 })
7200 .cloned()
7201 .collect::<Vec<_>>();
7202 if incompatible.is_empty() {
7203 return Ok(false);
7204 }
7205 let mut rejections = Vec::new();
7206 for commit in &incompatible {
7207 let results = commit
7208 .ops
7209 .iter()
7210 .enumerate()
7211 .map(|(op_index, operation)| {
7212 let rejection = RejectionRecord {
7213 client_commit_id: commit.client_commit_id.clone(),
7214 op_index: op_index as i32,
7215 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
7216 message: "the persisted commit cannot encode under the current schema"
7217 .to_owned(),
7218 retryable: false,
7219 details: None,
7220 operation: Some(CommitOperation::from(operation)),
7221 };
7222 rejections.push(rejection.clone());
7223 CommitOperationOutcome::Error { rejection }
7224 })
7225 .collect::<Vec<_>>();
7226 self.persist_commit_outcome(
7227 &commit.client_commit_id,
7228 CommitOutcomeStatus::Rejected,
7229 &results,
7230 Some(&commit.ops),
7231 )?;
7232 self.delete_outbox_persisted(&commit.client_commit_id)?;
7233 }
7234 self.prune_commit_outcomes()?;
7235 let incompatible_ids = incompatible
7236 .iter()
7237 .map(|commit| commit.client_commit_id.as_str())
7238 .collect::<BTreeSet<_>>();
7239 self.outbox
7240 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
7241 self.rejections.extend(rejections);
7242 Ok(true)
7243 }
7244
7245 fn create_synced_tables(&self) -> Result<(), String> {
7248 for table in &self.schema.tables {
7249 for (full, index_prefix) in [
7253 (base_table(&table.name), "_syncular_base_"),
7254 (visible_table(&table.name), ""),
7255 ] {
7256 let mut cols: Vec<String> =
7257 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
7258 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
7259 let sql = format!(
7260 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
7261 cols.join(", "),
7262 quote_ident(&table.primary_key)
7263 );
7264 self.conn
7265 .execute(&sql, [])
7266 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7267 for index in &table.indexes {
7271 let unique = if index.unique { "UNIQUE " } else { "" };
7272 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
7273 let cols_sql = index
7274 .columns
7275 .iter()
7276 .map(|c| quote_ident(c))
7277 .collect::<Vec<_>>()
7278 .join(", ");
7279 let index_sql = format!(
7280 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
7281 );
7282 self.conn
7283 .execute(&index_sql, [])
7284 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7285 }
7286 }
7287 }
7288 for table in &self.schema.tables {
7291 for index in &table.fts_indexes {
7292 self.create_fts_projection(table, index)?;
7293 }
7294 }
7295 Ok(())
7296 }
7297
7298 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
7299 let count: i64 = self
7300 .conn
7301 .query_row(
7302 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
7303 rusqlite::params![index.name],
7304 |row| row.get(0),
7305 )
7306 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7307 Ok(count > 0)
7308 }
7309
7310 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
7311 for suffix in ["ai", "ad", "au"] {
7312 self.conn
7313 .execute(
7314 &format!(
7315 "DROP TRIGGER IF EXISTS {}",
7316 quote_ident(&format!("{}_{suffix}", index.name))
7317 ),
7318 [],
7319 )
7320 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7321 }
7322 Ok(())
7323 }
7324
7325 fn create_fts_triggers(
7326 &self,
7327 table: &TableSchema,
7328 index: &FtsIndexSchema,
7329 ) -> Result<(), String> {
7330 let fts = quote_ident(&index.name);
7331 let source = visible_table(&table.name);
7332 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7333 let pk = quote_ident(&table.primary_key);
7334 let projection_columns = std::iter::once(source_id.clone())
7335 .chain(index.columns.iter().map(|column| quote_ident(column)))
7336 .collect::<Vec<_>>()
7337 .join(", ");
7338 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
7339 .chain(
7340 index
7341 .columns
7342 .iter()
7343 .map(|column| format!("new.{}", quote_ident(column))),
7344 )
7345 .collect::<Vec<_>>()
7346 .join(", ");
7347 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7348 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)");
7349 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)");
7350 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})");
7351 let bi = quote_ident(&format!("{}_bi", index.name));
7352 let ai = quote_ident(&format!("{}_ai", index.name));
7353 let ad = quote_ident(&format!("{}_ad", index.name));
7354 let au = quote_ident(&format!("{}_au", index.name));
7355 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
7356 let sql = format!(
7357 "DROP TRIGGER IF EXISTS {bi};
7358 DROP TRIGGER IF EXISTS {ai};
7359 DROP TRIGGER IF EXISTS {ad};
7360 DROP TRIGGER IF EXISTS {au};
7361 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
7362 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
7363 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
7364 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
7365 );
7366 self.conn
7367 .execute_batch(&sql)
7368 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7369 }
7370
7371 fn rebuild_fts_projection(
7372 &self,
7373 table: &TableSchema,
7374 index: &FtsIndexSchema,
7375 ) -> Result<(), String> {
7376 let fts = quote_ident(&index.name);
7377 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7378 let pk = quote_ident(&table.primary_key);
7379 let indexed_columns = index
7380 .columns
7381 .iter()
7382 .map(|column| quote_ident(column))
7383 .collect::<Vec<_>>();
7384 let projection_columns = std::iter::once(source_id.clone())
7385 .chain(indexed_columns.iter().cloned())
7386 .collect::<Vec<_>>()
7387 .join(", ");
7388 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7389 let sql = format!(
7390 "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};",
7391 columns = indexed_columns.join(", "),
7392 source = visible_table(&table.name),
7393 );
7394 self.conn
7395 .execute_batch(&sql)
7396 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7397 }
7398
7399 fn create_fts_projection(
7400 &self,
7401 table: &TableSchema,
7402 index: &FtsIndexSchema,
7403 ) -> Result<(), String> {
7404 self.begin_observation("syncular_fts_schema")?;
7405 let result = (|| {
7406 let existed = self.fts_projection_exists(index)?;
7407 let tokenizer = index.tokenize.replace('\'', "''");
7408 let columns = index
7409 .columns
7410 .iter()
7411 .map(|column| quote_ident(column))
7412 .collect::<Vec<_>>()
7413 .join(", ");
7414 let sql = format!(
7415 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
7416 fts = quote_ident(&index.name),
7417 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
7418 );
7419 self.conn.execute(&sql, []).map_err(|error| {
7420 format!(
7421 "cannot create local FTS5 projection {:?}: {error}",
7422 index.name
7423 )
7424 })?;
7425 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7426 let mapped: bool = self
7427 .conn
7428 .query_row(
7429 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
7430 [format!("_syncular_fts_{}", index.name)],
7431 |row| row.get(0),
7432 )
7433 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7434 self.conn.execute_batch(&format!("CREATE TABLE IF NOT EXISTS {mapping}(id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)")).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7435 if !mapped && existed {
7436 self.conn
7437 .execute_batch(&format!(
7438 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
7439 quote_ident(FTS_SOURCE_ID_COLUMN),
7440 quote_ident(&index.name)
7441 ))
7442 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7443 }
7444 self.create_fts_triggers(table, index)?;
7445 if !existed {
7446 self.rebuild_fts_projection(table, index)?;
7447 }
7448 Ok(())
7449 })();
7450 if let Err(error) = result {
7451 self.rollback_observation("syncular_fts_schema");
7452 return Err(error);
7453 }
7454 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
7455 self.rollback_observation("syncular_fts_schema");
7456 return Err(error.to_string());
7457 }
7458 Ok(())
7459 }
7460
7461 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
7464 let state = serde_json::json!({
7465 "requested": scope_map_to_json(&sub.requested),
7466 "params": sub.params,
7467 "cursor": sub.cursor,
7468 "bootstrapState": sub.bootstrap_state,
7469 "status": sub.state.name(),
7470 "reasonCode": sub.reason_code,
7471 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7472 "syncedOnce": sub.synced_once,
7473 });
7474 self.conn.execute(
7475 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
7476 rusqlite::params![sub.id, sub.table, state.to_string()],
7477 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7478 }
7479
7480 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
7481 let ops: Vec<Value> = commit
7482 .ops
7483 .iter()
7484 .map(|op| {
7485 serde_json::json!({
7486 "op": if op.upsert { "upsert" } else { "delete" },
7487 "table": op.table,
7488 "rowId": op.row_id,
7489 "baseVersion": op.base_version,
7490 "values": op.values.clone().map(Value::Object),
7491 })
7492 })
7493 .collect();
7494 self.conn
7495 .execute(
7496 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
7497 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
7498 )
7499 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7500 for operation in &commit.ops {
7501 let Some(values) = &operation.values else {
7502 continue;
7503 };
7504 let Some(table) = self.schema.table(&operation.table) else {
7505 continue;
7506 };
7507 for column in table
7508 .columns
7509 .iter()
7510 .filter(|column| column.ty == ColumnType::BlobRef)
7511 {
7512 let Some(Value::String(raw)) = values.get(&column.name) else {
7513 continue;
7514 };
7515 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
7516 continue;
7517 };
7518 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
7519 continue;
7520 };
7521 self.conn
7522 .execute(
7523 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
7524 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
7525 rusqlite::params![commit.client_commit_id, blob_id],
7526 )
7527 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7528 }
7529 }
7530 Ok(())
7531 }
7532
7533 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
7534 self.conn
7535 .execute(
7536 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
7537 rusqlite::params![client_commit_id],
7538 )
7539 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7540 self.conn
7541 .execute(
7542 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
7543 rusqlite::params![client_commit_id],
7544 )
7545 .map(|_| ())
7546 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7547 }
7548
7549 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
7550 match status {
7551 CommitOutcomeStatus::Applied => "applied",
7552 CommitOutcomeStatus::Cached => "cached",
7553 CommitOutcomeStatus::Conflict => "conflict",
7554 CommitOutcomeStatus::Rejected => "rejected",
7555 }
7556 }
7557
7558 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
7559 match resolution {
7560 CommitOutcomeResolution::Active => "active",
7561 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
7562 CommitOutcomeResolution::Superseded => "superseded",
7563 CommitOutcomeResolution::Dismissed => "dismissed",
7564 }
7565 }
7566
7567 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
7568 match value {
7569 "applied" => Ok(CommitOutcomeStatus::Applied),
7570 "cached" => Ok(CommitOutcomeStatus::Cached),
7571 "conflict" => Ok(CommitOutcomeStatus::Conflict),
7572 "rejected" => Ok(CommitOutcomeStatus::Rejected),
7573 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
7574 }
7575 }
7576
7577 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
7578 match value {
7579 "active" => Ok(CommitOutcomeResolution::Active),
7580 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
7581 "superseded" => Ok(CommitOutcomeResolution::Superseded),
7582 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
7583 _ => Err(format!(
7584 "invalid persisted commit outcome resolution {value:?}"
7585 )),
7586 }
7587 }
7588
7589 fn persist_commit_outcome(
7590 &self,
7591 client_commit_id: &str,
7592 status: CommitOutcomeStatus,
7593 results: &[CommitOperationOutcome],
7594 operations: Option<&[OutboxOp]>,
7595 ) -> Result<(), String> {
7596 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
7597 let operations_json = operations
7598 .map(|items| {
7599 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
7600 })
7601 .transpose()
7602 .map_err(|error| error.to_string())?;
7603 self.conn
7604 .execute(
7605 "INSERT INTO _syncular_commit_outcomes (
7606 client_commit_id, status, recorded_at_ms, results_json,
7607 operations_json, resolution
7608 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
7609 rusqlite::params![
7610 client_commit_id,
7611 Self::outcome_status_name(status),
7612 self.clock_now_ms(),
7613 results_json,
7614 operations_json
7615 ],
7616 )
7617 .map(|_| ())
7618 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7619 }
7620
7621 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
7622 let StoredCommitOutcomeRow {
7623 sequence,
7624 client_commit_id,
7625 status,
7626 recorded_at_ms,
7627 results_json,
7628 operations_json,
7629 resolution,
7630 resolved_at_ms,
7631 replacement_client_commit_id,
7632 } = row;
7633 Ok(CommitOutcome {
7634 sequence,
7635 client_commit_id,
7636 status: Self::parse_outcome_status(&status)?,
7637 recorded_at_ms,
7638 results: serde_json::from_str(&results_json)
7639 .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
7640 operations: operations_json
7641 .map(|value| {
7642 serde_json::from_str(&value).map_err(|error| {
7643 format!("invalid persisted commit outcome operations: {error}")
7644 })
7645 })
7646 .transpose()?,
7647 retained_rows: None,
7648 resolution: Self::parse_outcome_resolution(&resolution)?,
7649 resolved_at_ms,
7650 replacement_client_commit_id,
7651 })
7652 }
7653
7654 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
7655 if let Some((commit, initial)) = self
7656 .failed_commits
7657 .iter()
7658 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
7659 {
7660 let mut rows = Vec::new();
7661 for operation in &commit.ops {
7662 let table = self
7663 .schema
7664 .table(&operation.table)
7665 .ok_or_else(|| "sync.unknown_table".to_owned())?;
7666 let key =
7667 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
7668 .map_err(|_| "sync.local_corrupt".to_owned())?;
7669 let base = self.stored_row(table, &operation.row_id, true);
7670 rows.push(RetainedCommitRow {
7671 table: operation.table.clone(),
7672 row_id: operation.row_id.clone(),
7673 local_row: if operation.upsert {
7674 initial
7675 .get(&key)
7676 .and_then(Value::as_object)
7677 .cloned()
7678 .or_else(|| operation.values.clone())
7679 } else {
7680 None
7681 },
7682 server_row: base.as_ref().map(|(values, _)| {
7683 table
7684 .columns
7685 .iter()
7686 .zip(values)
7687 .map(|(column, value)| {
7688 (column.name.clone(), column_value_to_json(value))
7689 })
7690 .collect()
7691 }),
7692 server_version: base.map(|(_, version)| version),
7693 });
7694 }
7695 outcome.retained_rows = Some(rows);
7696 }
7697 Ok(outcome)
7698 }
7699
7700 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
7701 let row = self
7702 .conn
7703 .query_row(
7704 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7705 resolution, resolved_at_ms, replacement_client_commit_id
7706 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
7707 rusqlite::params![client_commit_id],
7708 |row| {
7709 Ok(StoredCommitOutcomeRow {
7710 sequence: row.get(0)?,
7711 client_commit_id: row.get(1)?,
7712 status: row.get(2)?,
7713 recorded_at_ms: row.get(3)?,
7714 results_json: row.get(4)?,
7715 operations_json: row.get(5)?,
7716 resolution: row.get(6)?,
7717 resolved_at_ms: row.get(7)?,
7718 replacement_client_commit_id: row.get(8)?,
7719 })
7720 },
7721 )
7722 .optional()
7723 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7724 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
7725 .transpose()
7726 }
7727
7728 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
7729 if query.limit == Some(0) {
7730 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
7731 }
7732 let mut sql = String::from(
7733 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7734 resolution, resolved_at_ms, replacement_client_commit_id
7735 FROM _syncular_commit_outcomes",
7736 );
7737 if query.active_only {
7738 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
7739 }
7740 sql.push_str(" ORDER BY seq DESC");
7741 if let Some(limit) = query.limit {
7742 sql.push_str(&format!(" LIMIT {limit}"));
7743 }
7744 let mut stmt = self
7745 .conn
7746 .prepare(&sql)
7747 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7748 let rows = stmt
7749 .query_map([], |row| {
7750 Ok(StoredCommitOutcomeRow {
7751 sequence: row.get(0)?,
7752 client_commit_id: row.get(1)?,
7753 status: row.get(2)?,
7754 recorded_at_ms: row.get(3)?,
7755 results_json: row.get(4)?,
7756 operations_json: row.get(5)?,
7757 resolution: row.get(6)?,
7758 resolved_at_ms: row.get(7)?,
7759 replacement_client_commit_id: row.get(8)?,
7760 })
7761 })
7762 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7763 let mut outcomes = Vec::new();
7764 for row in rows {
7765 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
7766 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
7767 )?)?);
7768 }
7769 Ok(outcomes)
7770 }
7771
7772 fn prune_commit_outcomes(&self) -> Result<(), String> {
7773 #[cfg(test)]
7774 self.outcome_prune_count
7775 .set(self.outcome_prune_count.get() + 1);
7776 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
7777 let count = self
7778 .conn
7779 .query_row(
7780 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
7781 [],
7782 |row| row.get::<_, i64>(0),
7783 )
7784 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
7785 as usize;
7786 let excess = count.saturating_sub(max_entries);
7787 if excess == 0 {
7788 return Ok(());
7789 }
7790 let mut stmt = self
7791 .conn
7792 .prepare(
7793 "SELECT seq FROM _syncular_commit_outcomes
7794 WHERE status IN ('applied', 'cached') OR resolution != 'active'
7795 ORDER BY seq ASC LIMIT ?1",
7796 )
7797 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7798 let rows = stmt
7799 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
7800 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7801 let sequences = rows
7802 .collect::<Result<Vec<_>, _>>()
7803 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7804 drop(stmt);
7805 for sequence in sequences {
7806 self.conn
7807 .execute(
7808 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
7809 rusqlite::params![sequence],
7810 )
7811 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7812 }
7813 Ok(())
7814 }
7815
7816 pub fn subscribe(
7819 &mut self,
7820 id: String,
7821 table: String,
7822 scopes: Vec<(String, Vec<String>)>,
7823 params: Option<String>,
7824 ) -> Result<(), String> {
7825 if self.schema.table(&table).is_none() {
7826 return Err(format!("unknown table {table:?}"));
7827 }
7828 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
7829 let same_intent = existing.table == table
7830 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
7831 && existing.params == params;
7832 if same_intent {
7833 return Ok(());
7834 }
7835 return Err(
7836 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
7837 .to_owned(),
7838 );
7839 }
7840 let sub = Subscription {
7841 id: id.clone(),
7842 table,
7843 requested: scopes,
7844 params,
7845 cursor: -1,
7846 bootstrap_state: None,
7847 state: SubState::Active,
7848 reason_code: None,
7849 effective: None,
7850 synced_once: false,
7851 };
7852 self.persist_sub(&sub)?;
7853 self.subs.push(sub);
7854 Ok(())
7855 }
7856
7857 pub fn unsubscribe(&mut self, id: &str) {
7858 self.subs.retain(|s| s.id != id);
7859 let _ = self.conn.execute(
7860 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7861 rusqlite::params![id],
7862 );
7863 }
7864
7865 pub fn set_window(
7873 &mut self,
7874 base: &WindowBase,
7875 units: &[String],
7876 ) -> Result<CommandEffects, String> {
7877 let table = self
7878 .schema
7879 .table(&base.table)
7880 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
7881 if table.scope_column(&base.variable).is_none() {
7882 return Err(format!(
7883 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
7884 base.table, base.variable
7885 ));
7886 }
7887 let base_key = window_base_key(base);
7888 let wanted: std::collections::HashSet<&String> = units.iter().collect();
7889 let live = self.load_window_units(&base_key);
7890 self.begin_observation("syncular_window")?;
7891 let mut batch = ChangeAccumulator::default();
7892 let mut changed = false;
7893
7894 let prior_subs = self.subs.clone();
7895 let widened = (|| {
7896 for unit in units {
7898 if live.iter().any(|(u, _)| u == unit) {
7899 continue;
7900 }
7901 let sub_id = derive_sub_id(base, unit);
7902 self.delete_pending_evict(&sub_id)?;
7903 self.insert_window_unit(&base_key, unit, &sub_id)?;
7904 self.subscribe(
7905 sub_id,
7906 base.table.clone(),
7907 unit_scopes(base, unit),
7908 base.params.clone(),
7909 )?;
7910 batch.window(&base_key, &base.table, unit);
7911 changed = true;
7912 }
7913
7914 self.finish_observation("syncular_window", batch)
7915 })();
7916 if let Err(error) = widened {
7917 self.rollback_observation("syncular_window");
7918 self.subs = prior_subs;
7919 return Err(error);
7920 }
7921 for (unit, sub_id) in live {
7922 if wanted.contains(&unit) {
7923 continue;
7924 }
7925 self.rebuild_overlay_if_dirty();
7926 let prior_subs = self.subs.clone();
7927 self.begin_observation("syncular_window")?;
7928 let effective = self
7929 .subs
7930 .iter()
7931 .find(|sub| sub.id == sub_id)
7932 .and_then(|sub| sub.effective.clone())
7933 .unwrap_or_else(|| unit_scopes(base, &unit));
7934 let mut batch = ChangeAccumulator::default();
7935 self.record_scope_map(&mut batch, &base.table, &effective);
7936 batch.window(&base_key, &base.table, &unit);
7937 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
7938 let remaining = match result {
7939 Ok(remaining) => remaining,
7940 Err(error) => {
7941 self.rollback_observation("syncular_window");
7942 self.subs = prior_subs;
7943 return Err(error);
7944 }
7945 };
7946 if let Err(error) = self.finish_observation("syncular_window", batch) {
7947 self.rollback_observation("syncular_window");
7948 self.subs = prior_subs;
7949 return Err(error);
7950 }
7951 if remaining {
7952 self.drain_pending_evictions()?;
7953 }
7954 changed = true;
7955 }
7956 Ok(if changed {
7957 CommandEffects::interactive()
7958 } else {
7959 CommandEffects::none()
7960 })
7961 }
7962
7963 pub fn window_state(&self, base: &WindowBase) -> WindowState {
7968 let mut units = Vec::new();
7969 let mut pending = Vec::new();
7970 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
7971 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
7972 Some(sub) => {
7973 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
7974 }
7975 None => true,
7976 };
7977 if is_pending {
7978 pending.push(unit.clone());
7979 }
7980 units.push(unit);
7981 }
7982 WindowState { units, pending }
7983 }
7984
7985 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
7986 let mut stmt = match self
7987 .conn
7988 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
7989 {
7990 Ok(stmt) => stmt,
7991 Err(_) => return Vec::new(),
7992 };
7993 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
7994 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7995 });
7996 match rows {
7997 Ok(rows) => rows.filter_map(Result::ok).collect(),
7998 Err(_) => Vec::new(),
7999 }
8000 }
8001
8002 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
8003 let mut stmt = match self.conn.prepare(
8004 "SELECT windows.base, windows.unit, subscriptions.tbl
8005 FROM _syncular_windows AS windows
8006 JOIN _syncular_subscriptions AS subscriptions
8007 ON subscriptions.id = windows.sub_id
8008 ORDER BY windows.base, windows.unit",
8009 ) {
8010 Ok(stmt) => stmt,
8011 Err(_) => return Vec::new(),
8012 };
8013 let rows = stmt.query_map([], |row| {
8014 Ok((
8015 row.get::<_, String>(0)?,
8016 row.get::<_, String>(1)?,
8017 row.get::<_, String>(2)?,
8018 ))
8019 });
8020 match rows {
8021 Ok(rows) => rows.filter_map(Result::ok).collect(),
8022 Err(_) => Vec::new(),
8023 }
8024 }
8025
8026 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
8027 self.conn
8028 .query_row(
8029 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
8030 rusqlite::params![sub_id],
8031 |row| Ok((row.get(0)?, row.get(1)?)),
8032 )
8033 .ok()
8034 }
8035
8036 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
8037 self.conn
8038 .execute(
8039 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
8040 rusqlite::params![base_key, unit, sub_id],
8041 )
8042 .map(|_| ())
8043 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8044 }
8045
8046 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
8047 self.conn
8048 .execute(
8049 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
8050 rusqlite::params![base_key, unit],
8051 )
8052 .map(|_| ())
8053 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8054 }
8055
8056 fn evict_unit(
8063 &mut self,
8064 base_key: &str,
8065 base: &WindowBase,
8066 unit: &str,
8067 sub_id: &str,
8068 ) -> Result<bool, String> {
8069 let effective = self
8070 .subs
8071 .iter()
8072 .find(|s| s.id == sub_id)
8073 .and_then(|s| s.effective.clone())
8074 .unwrap_or_else(|| unit_scopes(base, unit));
8075 let pinned = self.pinned_row_ids(&base.table);
8076 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
8077 self.delete_window_unit(base_key, unit)?;
8078 self.conn
8079 .execute(
8080 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8081 [sub_id],
8082 )
8083 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8084 self.subs.retain(|sub| sub.id != sub_id);
8085 if deferred {
8086 self.save_pending_evict(sub_id, &base.table, &effective)?;
8087 } else {
8088 self.delete_pending_evict(sub_id)?;
8089 }
8090 Ok(remaining)
8091 }
8092
8093 fn evict_scope_rows(
8097 &self,
8098 table_name: &str,
8099 effective: &[(String, Vec<String>)],
8100 pinned: &std::collections::HashSet<String>,
8101 ) -> Result<(bool, bool), String> {
8102 if effective.is_empty() {
8103 return Ok((false, false));
8104 }
8105 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
8106 let mut clauses = Vec::new();
8107 let mut params: Vec<SqlValue> = Vec::new();
8108 for (variable, values) in effective {
8109 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
8110 if values.is_empty() {
8111 return Ok((false, false));
8112 }
8113 let holes = values
8114 .iter()
8115 .map(|v| {
8116 params.push(SqlValue::Text(v.clone()));
8117 "?"
8118 })
8119 .collect::<Vec<_>>()
8120 .join(", ");
8121 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
8122 }
8123 let scope = clauses.join(" AND ");
8124 let scope_params = params.len();
8125 let pk = quote_ident(&table.primary_key);
8126 if !pinned.is_empty() {
8127 let holes = pinned
8128 .iter()
8129 .map(|id| {
8130 params.push(SqlValue::Text(id.clone()));
8131 "?"
8132 })
8133 .collect::<Vec<_>>()
8134 .join(", ");
8135 clauses.push(format!("{pk} NOT IN ({holes})"));
8136 }
8137 let predicate = clauses.join(" AND ");
8138 let base = base_table(table_name);
8139 let visible = visible_table(table_name);
8140 let mut ids: Vec<SqlValue> = Vec::new();
8141 for source in [&base, &visible] {
8142 let mut stmt = self
8143 .conn
8144 .prepare_cached(&format!(
8145 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
8146 ))
8147 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8148 ids = stmt
8149 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
8150 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8151 .collect::<Result<_, _>>()
8152 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8153 if !ids.is_empty() {
8154 break;
8155 }
8156 }
8157 if !ids.is_empty() {
8158 let holes = vec!["?"; ids.len()].join(", ");
8159 for source in [&base, &visible] {
8160 self.conn
8161 .execute(
8162 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
8163 rusqlite::params_from_iter(&ids),
8164 )
8165 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8166 }
8167 }
8168 let mut remaining = false;
8169 let mut deferred = false;
8170 for source in [&base, &visible] {
8171 remaining |= self
8172 .conn
8173 .query_row(
8174 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
8175 rusqlite::params_from_iter(¶ms),
8176 |row| row.get::<_, bool>(0),
8177 )
8178 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8179 deferred |= self
8180 .conn
8181 .query_row(
8182 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
8183 rusqlite::params_from_iter(¶ms[..scope_params]),
8184 |row| row.get::<_, bool>(0),
8185 )
8186 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8187 }
8188 Ok((remaining, deferred))
8189 }
8190
8191 fn drain_pending_evictions(&mut self) -> Result<(), String> {
8193 let pending = self.load_pending_evictions()?;
8194 if pending.is_empty() {
8195 return Ok(());
8196 }
8197 self.rebuild_overlay_if_dirty();
8198 for (sub_id, table_name, effective) in pending {
8199 if self.schema.table(&table_name).is_none() {
8200 self.delete_pending_evict(&sub_id)?;
8201 continue;
8202 }
8203 loop {
8204 std::thread::yield_now();
8205 self.begin_observation("syncular_evict")?;
8206 let pinned = self.pinned_row_ids(&table_name);
8207 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
8208 let (remaining, deferred) = match result {
8209 Ok(result) => result,
8210 Err(error) => {
8211 self.rollback_observation("syncular_evict");
8212 return Err(error);
8213 }
8214 };
8215 if !deferred {
8216 if let Err(error) = self.delete_pending_evict(&sub_id) {
8217 self.rollback_observation("syncular_evict");
8218 return Err(error);
8219 }
8220 }
8221 let mut batch = ChangeAccumulator::default();
8222 self.record_scope_map(&mut batch, &table_name, &effective);
8223 if let Err(error) = self.finish_observation("syncular_evict", batch) {
8224 self.rollback_observation("syncular_evict");
8225 return Err(error);
8226 }
8227 if !remaining {
8228 break;
8229 }
8230 }
8231 }
8232 Ok(())
8233 }
8234
8235 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
8238 let mut pinned = std::collections::HashSet::new();
8239 for commit in self
8240 .outbox
8241 .iter()
8242 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
8243 {
8244 for op in &commit.ops {
8245 if op.table == table {
8246 pinned.insert(op.row_id.clone());
8247 }
8248 }
8249 }
8250 pinned
8251 }
8252
8253 fn save_pending_evict(
8254 &self,
8255 sub_id: &str,
8256 table: &str,
8257 effective: &[(String, Vec<String>)],
8258 ) -> Result<(), String> {
8259 self.conn.execute(
8260 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
8261 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
8262 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8263 }
8264
8265 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
8266 self.conn
8267 .execute(
8268 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
8269 rusqlite::params![sub_id],
8270 )
8271 .map(|_| ())
8272 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8273 }
8274
8275 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
8276 let mut stmt = self
8277 .conn
8278 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
8279 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8280 let rows = stmt
8281 .query_map([], |row| {
8282 Ok((
8283 row.get::<_, String>(0)?,
8284 row.get::<_, String>(1)?,
8285 row.get::<_, String>(2)?,
8286 ))
8287 })
8288 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8289 rows.map(|row| {
8290 let (sub_id, table, json) =
8291 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8292 let value: Value = serde_json::from_str(&json)
8293 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8294 let effective = json_to_scope_map(&value)
8295 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8296 Ok((sub_id, table, effective))
8297 })
8298 .collect()
8299 }
8300
8301 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
8303 self.retain_failed_commits = enabled;
8304 self.failed_commits = self.load_failed_commits()?;
8305 self.overlay_dirty.set(true);
8306 Ok(())
8307 }
8308
8309 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
8310 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
8311 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8312 let rows = statement
8313 .query_map([], |row| {
8314 Ok((
8315 row.get::<_, String>(0)?,
8316 row.get::<_, String>(1)?,
8317 row.get::<_, String>(2)?,
8318 ))
8319 })
8320 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8321 rows.map(|row| {
8322 let (id, operations, initial) =
8323 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8324 let operations: Vec<CommitOperation> =
8325 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
8326 let initial =
8327 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
8328 Ok((
8329 OutboxCommit {
8330 client_commit_id: id,
8331 ops: operations
8332 .into_iter()
8333 .map(|op| OutboxOp {
8334 upsert: op.op == "upsert",
8335 table: op.table,
8336 row_id: op.row_id,
8337 base_version: op.base_version,
8338 values: op.values,
8339 })
8340 .collect(),
8341 },
8342 initial,
8343 ))
8344 })
8345 .collect()
8346 }
8347
8348 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
8349 let mut initial = Map::new();
8350 for operation in operations {
8351 let table = self
8352 .schema
8353 .table(&operation.table)
8354 .ok_or_else(|| "sync.unknown_table".to_owned())?;
8355 if let Some((values, _)) = self
8356 .stored_row(table, &operation.row_id, true)
8357 .or_else(|| self.visible_row(table, &operation.row_id))
8358 {
8359 let mut intended: Map<String, Value> = table
8360 .columns
8361 .iter()
8362 .zip(values.iter())
8363 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
8364 .collect();
8365 if let Some(patch) = &operation.values {
8366 intended.extend(patch.clone());
8367 }
8368 initial.insert(
8369 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8370 .map_err(|_| "sync.local_corrupt".to_owned())?,
8371 Value::Object(intended),
8372 );
8373 }
8374 }
8375 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
8376 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
8377 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
8378 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8379 self.failed_commits.push((
8380 OutboxCommit {
8381 client_commit_id: id.to_owned(),
8382 ops: operations.to_vec(),
8383 },
8384 initial,
8385 ));
8386 Ok(())
8387 }
8388
8389 fn drop_failed_commits(
8390 &mut self,
8391 matches: impl Fn(&str, &Map<String, Value>) -> bool,
8392 ) -> Result<Vec<OutboxCommit>, String> {
8393 let mut doomed = Vec::new();
8394 for (commit, initial) in &self.failed_commits {
8395 for operation in &commit.ops {
8396 let key =
8397 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8398 .map_err(|_| "sync.local_corrupt".to_owned())?;
8399 if initial
8400 .get(&key)
8401 .and_then(Value::as_object)
8402 .is_some_and(|values| matches(&operation.table, values))
8403 {
8404 doomed.push(commit.clone());
8405 break;
8406 }
8407 }
8408 }
8409 for commit in &doomed {
8410 self.conn
8411 .execute(
8412 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
8413 rusqlite::params![commit.client_commit_id],
8414 )
8415 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8416 }
8417 self.failed_commits.retain(|(commit, _)| {
8418 !doomed
8419 .iter()
8420 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
8421 });
8422 if !doomed.is_empty() {
8423 self.overlay_dirty.set(true);
8424 }
8425 Ok(doomed)
8426 }
8427
8428 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
8429 if mutations.is_empty() {
8430 return Err("a commit must contain at least one operation (§6.1)".to_owned());
8431 }
8432 let mut ops = Vec::with_capacity(mutations.len());
8433 for mutation in mutations {
8434 match mutation {
8435 Mutation::Patch {
8436 table,
8437 values,
8438 base_version,
8439 } => {
8440 let schema_table = self
8441 .schema
8442 .table(&table)
8443 .ok_or_else(|| "sync.unknown_table".to_owned())?;
8444 let values = normalize_values_casing(schema_table, values)?;
8445 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
8446 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
8447 }
8448 Mutation::Upsert {
8449 table,
8450 values,
8451 base_version,
8452 } => {
8453 let schema_table = self
8454 .schema
8455 .table(&table)
8456 .ok_or_else(|| format!("unknown table {table:?}"))?;
8457 let values = normalize_values_casing(schema_table, values)?;
8461 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
8462 let values = full_row_values(schema_table, values)?;
8465 self.validate_author_encode(schema_table, &row_id, &values)?;
8471 ops.push(OutboxOp {
8472 upsert: true,
8473 table,
8474 row_id,
8475 base_version,
8476 values: Some(values),
8477 });
8478 }
8479 Mutation::Delete {
8480 table,
8481 row_id,
8482 base_version,
8483 } => {
8484 if self.schema.table(&table).is_none() {
8485 return Err(format!("unknown table {table:?}"));
8486 }
8487 ops.push(OutboxOp {
8488 upsert: false,
8489 table,
8490 row_id,
8491 base_version,
8492 values: None,
8493 });
8494 }
8495 }
8496 }
8497 self.record_outbox_commit(ops)
8498 }
8499
8500 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
8501 let commit = OutboxCommit {
8502 client_commit_id: uuid::Uuid::new_v4().to_string(),
8503 ops,
8504 };
8505 self.begin_observation("syncular_mutation")?;
8506 let mut batch = ChangeAccumulator::default();
8507 for op in &commit.ops {
8508 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
8509 if let Some(values) = &op.values {
8510 if let Some(table) = self.schema.table(&op.table) {
8511 for scope in &table.scope_variables {
8512 if let Some(Value::String(value)) = values.get(&scope.column) {
8513 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
8514 precise = true;
8515 }
8516 }
8517 }
8518 }
8519 if !precise {
8520 batch.table(&op.table);
8521 }
8522 }
8523 if let Err(error) = self.persist_outbox_insert(&commit) {
8524 self.rollback_observation("syncular_mutation");
8525 return Err(error);
8526 }
8527 let was_dirty = self.overlay_dirty.get();
8528 if was_dirty {
8533 self.rebuild_overlay();
8534 }
8535 if let Some(error) = commit
8536 .ops
8537 .iter()
8538 .find_map(|op| self.apply_outbox_op(op).err())
8539 {
8540 self.rollback_observation("syncular_mutation");
8541 self.overlay_dirty.set(was_dirty);
8542 return Err(error);
8543 }
8544 let id = commit.client_commit_id.clone();
8545 self.outbox.push(commit);
8546 batch.status = true;
8547 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
8548 self.rollback_observation("syncular_mutation");
8549 self.outbox.pop();
8550 self.overlay_dirty.set(was_dirty);
8551 return Err(error);
8552 }
8553 Ok(id)
8554 }
8555
8556 pub fn patch(
8561 &mut self,
8562 table: &str,
8563 row_id: &str,
8564 partial: Map<String, Value>,
8565 base_version: Option<i64>,
8566 ) -> Result<String, String> {
8567 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
8568 self.record_outbox_commit(vec![operation])
8569 }
8570
8571 fn prepare_patch(
8572 &self,
8573 table: &str,
8574 row_id: &str,
8575 partial: Map<String, Value>,
8576 base_version: Option<i64>,
8577 ) -> Result<OutboxOp, String> {
8578 let schema_table = self
8579 .schema
8580 .table(table)
8581 .ok_or_else(|| format!("unknown table {table:?}"))?;
8582 let partial = normalize_values_casing(schema_table, partial)?;
8583 if let Some(pk) = partial.get(&schema_table.primary_key) {
8584 if pk.as_str() != Some(row_id) {
8585 return Err(format!(
8586 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
8587 ));
8588 }
8589 }
8590 let exists: bool = self
8593 .conn
8594 .query_row(
8595 &format!(
8596 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
8597 visible_table(table),
8598 row_id_predicate(schema_table)
8599 ),
8600 rusqlite::params![row_id],
8601 |row| row.get(0),
8602 )
8603 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8604 if !exists {
8605 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
8606 }
8607 for scope in &schema_table.scope_variables {
8617 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
8618 continue;
8619 }
8620 let Some(column) = schema_table
8621 .columns
8622 .iter()
8623 .find(|column| column.name == scope.column)
8624 else {
8625 continue;
8626 };
8627 let stored = self
8628 .conn
8629 .query_row(
8630 &format!(
8631 "SELECT {} FROM {} WHERE {}",
8632 quote_ident(&scope.column),
8633 visible_table(&schema_table.name),
8634 row_id_predicate(schema_table)
8635 ),
8636 rusqlite::params![row_id],
8637 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
8638 )
8639 .ok();
8640 let incoming = json_to_column_value(column, partial.get(&scope.column))
8645 .ok()
8646 .map(|value| column_value_to_json(&value));
8647 let equal = stored.as_ref().is_some_and(|stored| {
8651 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
8652 });
8653 if !equal {
8654 return Err(format!(
8655 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
8656 scope.column
8657 ));
8658 }
8659 }
8660 let mut values = partial;
8661 for scope in &schema_table.scope_variables {
8664 values.remove(&scope.column);
8665 }
8666 values.insert(
8667 schema_table.primary_key.clone(),
8668 Value::from(row_id.to_owned()),
8669 );
8670 self.validate_author_encode(schema_table, row_id, &values)?;
8676 Ok(OutboxOp {
8677 upsert: true,
8678 table: table.to_owned(),
8679 row_id: row_id.to_owned(),
8680 base_version,
8681 values: Some(values),
8682 })
8683 }
8684
8685 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
8690 let selector = self.encryption.key_id_columns.get(&table.name)?;
8691 let index = table
8692 .columns
8693 .iter()
8694 .position(|column| &column.name == selector)?;
8695 let sql = format!(
8696 "SELECT {} FROM {} WHERE {}",
8697 quote_ident(selector),
8698 visible_table(&table.name),
8699 row_id_predicate(table)
8700 );
8701 let key: Option<String> = self
8702 .conn
8703 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
8704 .ok()?;
8705 let key = key.filter(|key| !key.is_empty())?;
8706 let mut fallback: Row = vec![None; table.columns.len()];
8707 fallback[index] = Some(ColumnValue::String(key));
8708 Some(fallback)
8709 }
8710
8711 fn validate_author_encode(
8716 &self,
8717 table: &TableSchema,
8718 row_id: &str,
8719 values: &Map<String, Value>,
8720 ) -> Result<(), String> {
8721 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
8722 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
8723 result => result.map(|_| ()),
8724 }
8725 }
8726
8727 fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
8733 if !self
8734 .schema
8735 .tables
8736 .iter()
8737 .any(|table| table.has_encrypted_columns())
8738 {
8739 return Ok(false);
8740 }
8741 let failures = self
8742 .outbox
8743 .iter()
8744 .filter_map(|commit| {
8745 commit
8746 .ops
8747 .iter()
8748 .find_map(|op| {
8749 let values = op.values.as_ref().filter(|_| op.upsert)?;
8750 let table = self.schema.table(&op.table)?;
8751 let fallback = self.stored_key_fallback(table, &op.row_id);
8752 match encode_sparse_row_json(
8753 table,
8754 &op.row_id,
8755 values,
8756 &self.encryption,
8757 fallback.as_ref(),
8758 ) {
8759 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
8760 _ => None,
8761 }
8762 })
8763 .map(|error| (commit.clone(), error))
8764 })
8765 .collect::<Vec<_>>();
8766 if failures.is_empty() {
8767 return Ok(false);
8768 }
8769 for (commit, message) in failures {
8770 if self.retain_failed_commits {
8771 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
8772 }
8773 let rejection = RejectionRecord {
8774 client_commit_id: commit.client_commit_id.clone(),
8775 op_index: 0,
8776 code: ENCRYPT_FAILED_CODE.to_owned(),
8777 message,
8778 retryable: false,
8779 details: None,
8780 operation: commit.ops.first().map(CommitOperation::from),
8781 };
8782 self.persist_commit_outcome(
8783 &commit.client_commit_id,
8784 CommitOutcomeStatus::Rejected,
8785 &[CommitOperationOutcome::Error {
8786 rejection: rejection.clone(),
8787 }],
8788 Some(&commit.ops),
8789 )?;
8790 self.delete_outbox_persisted(&commit.client_commit_id)?;
8791 self.outbox
8792 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
8793 self.rejections.push(rejection);
8794 }
8795 self.prune_commit_outcomes()?;
8796 self.overlay_dirty.set(true);
8797 self.rebuild_overlay();
8798 Ok(true)
8799 }
8800
8801 pub fn pending_commit_ids(&self) -> Vec<String> {
8802 self.outbox
8803 .iter()
8804 .map(|c| c.client_commit_id.clone())
8805 .collect()
8806 }
8807
8808 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
8812 let mut payloads = Vec::new();
8813 for commit in &self.outbox {
8814 for op in &commit.ops {
8815 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
8816 continue;
8817 };
8818 let Some(table) = self.schema.table(&op.table) else {
8819 continue;
8820 };
8821 let fallback = self.stored_key_fallback(table, &op.row_id);
8822 if let Ok(payload) = encode_sparse_row_json(
8823 table,
8824 &op.row_id,
8825 values,
8826 &self.encryption,
8827 fallback.as_ref(),
8828 ) {
8829 payloads.push(payload);
8830 }
8831 }
8832 }
8833 payloads
8834 }
8835
8836 pub fn conflicts(&self) -> &[ConflictRecord] {
8837 &self.conflicts
8838 }
8839
8840 pub fn rejections(&self) -> &[RejectionRecord] {
8841 &self.rejections
8842 }
8843
8844 pub fn resolve_commit_outcome(
8845 &mut self,
8846 input: ResolveCommitOutcomeInput,
8847 ) -> Result<CommitOutcome, String> {
8848 let current = self
8849 .commit_outcome(&input.client_commit_id)?
8850 .ok_or_else(|| {
8851 format!(
8852 "sync.outcome_not_found: no durable outcome exists for {:?}",
8853 input.client_commit_id
8854 )
8855 })?;
8856 if current.resolution != CommitOutcomeResolution::Active {
8857 return Ok(current);
8858 }
8859 if input.resolution == CommitOutcomeResolution::Active {
8860 return Err("sync.invalid_request: resolution must leave active state".to_owned());
8861 }
8862 match input.resolution {
8863 CommitOutcomeResolution::Superseded => {
8864 let replacement = input
8865 .replacement_client_commit_id
8866 .as_deref()
8867 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
8868 .ok_or_else(|| {
8869 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
8870 .to_owned()
8871 })?;
8872 let _ = replacement;
8873 }
8874 _ if input.replacement_client_commit_id.is_some() => {
8875 return Err(
8876 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
8877 .to_owned(),
8878 );
8879 }
8880 _ => {}
8881 }
8882 let allowed = match current.status {
8883 CommitOutcomeStatus::Conflict => matches!(
8884 input.resolution,
8885 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
8886 ),
8887 CommitOutcomeStatus::Rejected => {
8888 matches!(
8889 input.resolution,
8890 CommitOutcomeResolution::Superseded
8891 | CommitOutcomeResolution::ResolvedKeepServer
8892 )
8893 }
8894 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
8895 input.resolution == CommitOutcomeResolution::Dismissed
8896 }
8897 };
8898 if !allowed {
8899 return Err(format!(
8900 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
8901 input.resolution, current.status
8902 ));
8903 }
8904
8905 self.begin_observation("syncular_outcome_resolution")?;
8906 let previous_failed = self.failed_commits.clone();
8907 let previous_conflicts = self.conflicts.clone();
8908 let previous_rejections = self.rejections.clone();
8909 let result = (|| {
8910 self.conn
8911 .execute(
8912 "UPDATE _syncular_commit_outcomes
8913 SET resolution = ?1, resolved_at_ms = ?2,
8914 replacement_client_commit_id = ?3
8915 WHERE client_commit_id = ?4 AND resolution = 'active'",
8916 rusqlite::params![
8917 Self::outcome_resolution_name(input.resolution),
8918 self.clock_now_ms(),
8919 input.replacement_client_commit_id,
8920 input.client_commit_id
8921 ],
8922 )
8923 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8924 let resolved = self
8925 .commit_outcome(¤t.client_commit_id)?
8926 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
8927 self.conn
8928 .execute(
8929 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
8930 rusqlite::params![input.client_commit_id],
8931 )
8932 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8933 self.failed_commits
8934 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
8935 self.overlay_dirty.set(true);
8936 self.prune_commit_outcomes()?;
8937 Ok(resolved)
8938 })();
8939 let resolved = match result {
8940 Ok(outcome) => outcome,
8941 Err(error) => {
8942 self.rollback_observation("syncular_outcome_resolution");
8943 self.failed_commits = previous_failed;
8944 self.conflicts = previous_conflicts;
8945 self.rejections = previous_rejections;
8946 return Err(error);
8947 }
8948 };
8949 self.conflicts
8950 .retain(|record| record.client_commit_id != current.client_commit_id);
8951 self.rejections
8952 .retain(|record| record.client_commit_id != current.client_commit_id);
8953 let mut batch = ChangeAccumulator {
8954 conflicts: current.status == CommitOutcomeStatus::Conflict,
8955 rejections: current.status == CommitOutcomeStatus::Rejected,
8956 outcomes: true,
8957 ..ChangeAccumulator::default()
8958 };
8959 if let Some(operations) = ¤t.operations {
8960 for operation in operations {
8961 batch.table(&operation.table);
8962 }
8963 }
8964 self.rebuild_overlay_if_dirty();
8965 if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
8966 self.rollback_observation("syncular_outcome_resolution");
8967 self.failed_commits = previous_failed;
8968 self.conflicts = previous_conflicts;
8969 self.rejections = previous_rejections;
8970 return Err(error);
8971 }
8972 Ok(resolved)
8973 }
8974
8975 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
8976 self.schema_floor.as_ref()
8977 }
8978
8979 pub fn lease_state(&self) -> Option<&LeaseState> {
8981 self.lease_state.as_ref()
8982 }
8983
8984 fn record_lease_error(&mut self, code: &str) {
8987 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
8988 return;
8989 }
8990 let mut next = self.lease_state.clone().unwrap_or_default();
8991 next.error_code = Some(code.to_owned());
8992 self.set_lease_state(Some(next));
8993 }
8994
8995 fn set_lease_state(&mut self, next: Option<LeaseState>) {
8996 if self.lease_state == next {
8997 return;
8998 }
8999 if self.begin_observation("syncular_lease").is_err() {
9000 return;
9001 }
9002 self.lease_state = next;
9003 if let Some(lease) = &self.lease_state {
9004 if let Ok(json) = serde_json::to_string(lease) {
9005 self.set_meta(LEASE_STATE_KEY, &json);
9006 }
9007 } else {
9008 self.delete_meta(LEASE_STATE_KEY);
9009 }
9010 let batch = ChangeAccumulator {
9011 status: true,
9012 ..ChangeAccumulator::default()
9013 };
9014 if self.finish_observation("syncular_lease", batch).is_err() {
9015 self.rollback_observation("syncular_lease");
9016 }
9017 }
9018
9019 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
9020 if self.schema_floor == next {
9021 return;
9022 }
9023 if self.begin_observation("syncular_schema_floor").is_err() {
9024 return;
9025 }
9026 self.schema_floor = next;
9027 self.stopped = self.schema_floor.is_some();
9028 if let Some(floor) = &self.schema_floor {
9029 if let Ok(json) = serde_json::to_string(floor) {
9030 self.set_meta(SCHEMA_FLOOR_KEY, &json);
9031 }
9032 } else {
9033 self.delete_meta(SCHEMA_FLOOR_KEY);
9034 }
9035 let batch = ChangeAccumulator {
9036 status: true,
9037 ..ChangeAccumulator::default()
9038 };
9039 if self
9040 .finish_observation("syncular_schema_floor", batch)
9041 .is_err()
9042 {
9043 self.rollback_observation("syncular_schema_floor");
9044 }
9045 }
9046
9047 fn set_upgrading(&mut self, value: bool) {
9048 if self.upgrading == value {
9049 return;
9050 }
9051 if self.begin_observation("syncular_upgrading").is_err() {
9052 return;
9053 }
9054 self.upgrading = value;
9055 let batch = ChangeAccumulator {
9056 status: true,
9057 ..ChangeAccumulator::default()
9058 };
9059 if self
9060 .finish_observation("syncular_upgrading", batch)
9061 .is_err()
9062 {
9063 self.rollback_observation("syncular_upgrading");
9064 }
9065 }
9066
9067 pub fn sync_needed(&self) -> bool {
9068 self.sync_needed
9069 }
9070
9071 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
9072 let sub = self.subs.iter().find(|s| s.id == id)?;
9073 Some(SubscriptionStateView {
9074 id: sub.id.clone(),
9075 table: sub.table.clone(),
9076 status: sub.state.name().to_owned(),
9077 cursor: sub.cursor,
9078 has_resume_token: sub.bootstrap_state.is_some(),
9079 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
9080 reason_code: sub.reason_code.clone(),
9081 })
9082 }
9083
9084 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
9085 let schema_table = self
9086 .schema
9087 .table(table)
9088 .ok_or_else(|| format!("unknown table {table:?}"))?;
9089 let sql = format!(
9090 "SELECT * FROM {} ORDER BY {} ASC",
9091 visible_table(table),
9092 quote_ident(&schema_table.primary_key)
9093 );
9094 let mut stmt = self
9095 .conn
9096 .prepare(&sql)
9097 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9098 let mut rows = stmt
9099 .query([])
9100 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9101 let mut out = Vec::new();
9102 while let Some(row) = rows
9103 .next()
9104 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9105 {
9106 let mut values = Map::new();
9107 for (i, column) in schema_table.columns.iter().enumerate() {
9108 let value = row
9109 .get_ref(i)
9110 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9111 values.insert(column.name.clone(), sql_ref_to_json(column, value));
9112 }
9113 let version: i64 = row
9114 .get(schema_table.columns.len())
9115 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9116 let row_id = match values.get(&schema_table.primary_key) {
9117 Some(Value::String(s)) => s.clone(),
9118 Some(Value::Number(n)) => n.to_string(),
9119 Some(Value::Bool(b)) => b.to_string(),
9120 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
9121 };
9122 out.push(RowState {
9123 row_id,
9124 version,
9125 values,
9126 });
9127 }
9128 Ok(out)
9129 }
9130
9131 #[cfg(feature = "crdt-yjs")]
9147 fn crdt_column_bytes(
9148 &self,
9149 table: &str,
9150 row_id: &str,
9151 column: &str,
9152 ) -> Result<Option<Vec<u8>>, String> {
9153 let schema_table = self
9154 .schema
9155 .table(table)
9156 .ok_or_else(|| format!("unknown table {table:?}"))?;
9157 let col = schema_table
9158 .columns
9159 .iter()
9160 .find(|c| c.name == column)
9161 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
9162 if col.ty != ColumnType::Crdt {
9163 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
9164 }
9165 let sql = format!(
9166 "SELECT {} FROM {} WHERE {}",
9167 quote_ident(column),
9168 visible_table(table),
9169 row_id_predicate(schema_table)
9170 );
9171 let bytes: Option<Vec<u8>> = self
9172 .conn
9173 .prepare_cached(&sql)
9174 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9175 .query_row(rusqlite::params![row_id], |row| {
9176 row.get::<_, Option<Vec<u8>>>(0)
9177 })
9178 .map_err(|e| match e {
9179 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
9180 other => other.to_string(),
9181 })?;
9182 Ok(bytes)
9183 }
9184
9185 #[cfg(feature = "crdt-yjs")]
9189 pub fn crdt_text(
9190 &self,
9191 table: &str,
9192 row_id: &str,
9193 column: &str,
9194 name: &str,
9195 ) -> Result<String, String> {
9196 let bytes = self
9197 .crdt_column_bytes(table, row_id, column)?
9198 .unwrap_or_default();
9199 crate::crdt::text(&bytes, name)
9200 }
9201
9202 #[cfg(feature = "crdt-yjs")]
9206 pub fn crdt_insert_text(
9207 &mut self,
9208 table: &str,
9209 row_id: &str,
9210 column: &str,
9211 name: &str,
9212 index: u32,
9213 value: &str,
9214 ) -> Result<String, String> {
9215 let current = self
9216 .crdt_column_bytes(table, row_id, column)?
9217 .unwrap_or_default();
9218 let update = crate::crdt::insert_text(¤t, name, index, value)?;
9219 self.crdt_push_update(table, row_id, column, &update)
9220 }
9221
9222 #[cfg(feature = "crdt-yjs")]
9226 pub fn crdt_delete_text(
9227 &mut self,
9228 table: &str,
9229 row_id: &str,
9230 column: &str,
9231 name: &str,
9232 index: u32,
9233 len: u32,
9234 ) -> Result<String, String> {
9235 let current = self
9236 .crdt_column_bytes(table, row_id, column)?
9237 .unwrap_or_default();
9238 let update = crate::crdt::delete_text(¤t, name, index, len)?;
9239 self.crdt_push_update(table, row_id, column, &update)
9240 }
9241
9242 #[cfg(feature = "crdt-yjs")]
9247 pub fn crdt_apply_update(
9248 &mut self,
9249 table: &str,
9250 row_id: &str,
9251 column: &str,
9252 update: &[u8],
9253 ) -> Result<String, String> {
9254 let current = self
9255 .crdt_column_bytes(table, row_id, column)?
9256 .unwrap_or_default();
9257 let next = crate::crdt::apply_update(¤t, update)?;
9258 self.crdt_push_update(table, row_id, column, &next)
9259 }
9260
9261 #[cfg(feature = "crdt-yjs")]
9268 fn crdt_push_update(
9269 &mut self,
9270 table: &str,
9271 row_id: &str,
9272 column: &str,
9273 crdt_bytes: &[u8],
9274 ) -> Result<String, String> {
9275 let mut bytes_obj = Map::new();
9276 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
9277 let mut partial = Map::new();
9278 partial.insert(column.to_owned(), Value::Object(bytes_obj));
9279 self.patch(table, row_id, partial, None)
9280 }
9281
9282 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
9297 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
9298 }
9299
9300 #[cfg(feature = "bench-internals")]
9302 #[doc(hidden)]
9303 pub fn benchmark_connection(&mut self) -> &mut Connection {
9304 &mut self.conn
9305 }
9306
9307 #[cfg(feature = "bench-internals")]
9309 #[doc(hidden)]
9310 pub fn benchmark_phases(
9311 &self,
9312 enabled: Option<bool>,
9313 reset: bool,
9314 ) -> Result<Option<Value>, String> {
9315 self.benchmark_phases.configure(enabled, reset)?;
9316 Ok(self.benchmark_phases.snapshot())
9317 }
9318
9319 pub fn query_snapshot(
9323 &mut self,
9324 sql: &str,
9325 params: &[QueryValue],
9326 coverage: &[WindowCoverage],
9327 owner: Option<QueryOwner<'_>>,
9328 ) -> Result<QuerySnapshot, String> {
9329 let result = snapshot_connection(&self.conn, sql, params, coverage);
9330 if let Some(owner) = owner {
9331 self.record_query_read(owner, result.as_ref().err());
9332 }
9333 result.map_err(|failure| failure.to_string())
9334 }
9335
9336 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
9340 let position = self
9341 .query_failures
9342 .iter()
9343 .position(|entry| entry.id == owner.id);
9344 let Some(failure) = failure else {
9345 if let Some(index) = position {
9346 self.query_failures.remove(index);
9347 }
9348 return;
9349 };
9350 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
9351 .into_iter()
9352 .map(str::to_owned)
9353 .collect();
9354 let code = failure.code.unwrap_or("client.query_failed").to_owned();
9355 if position.is_some_and(|index| {
9356 let entry = &self.query_failures[index];
9357 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
9358 }) {
9359 return;
9360 }
9361 if let Some(index) = position {
9362 self.query_failures.remove(index);
9363 }
9364 self.query_failures.push(DiagnosticQueryFailure {
9365 id: owner.id.to_owned(),
9366 tables,
9367 code,
9368 sqlite_code: failure.sqlite_code,
9369 at_ms: self.clock_now_ms(),
9370 });
9371 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
9372 self.query_failures.remove(0);
9373 }
9374 }
9375
9376 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
9379 #[cfg(feature = "bench-internals")]
9380 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
9381 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
9382 let mut frames = vec![Frame::ReqHeader {
9383 client_id: self.client_id.clone(),
9384 schema_version: self.schema.version,
9385 log_epoch: log_epoch.clone(),
9386 }];
9387 let mut pushed_ids = Vec::new();
9388 let mut ops_in_request = 0usize;
9389 let mut deferred_commits = 0usize;
9390 #[cfg(feature = "bench-internals")]
9391 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
9392 for (index, commit) in self
9393 .outbox
9394 .iter()
9395 .take(if log_epoch.is_some() {
9396 self.outbox.len()
9397 } else {
9398 0
9399 })
9400 .enumerate()
9401 {
9402 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
9407 deferred_commits = self.outbox.len() - index;
9408 break;
9409 }
9410 ops_in_request += commit.ops.len();
9411 let operations = commit
9412 .ops
9413 .iter()
9414 .map(|op| {
9415 let payload = op.values.as_ref().and_then(|values| {
9416 let table = self.schema.table(&op.table)?;
9417 let fallback = self.stored_key_fallback(table, &op.row_id);
9426 encode_sparse_row_json(
9427 table,
9428 &op.row_id,
9429 values,
9430 &self.encryption,
9431 fallback.as_ref(),
9432 )
9433 .ok()
9434 });
9435 ssp2::model::Operation {
9436 table: op.table.clone(),
9437 row_id: op.row_id.clone(),
9438 op: if op.upsert { Op::Upsert } else { Op::Delete },
9439 base_version: op.base_version,
9440 payload,
9441 }
9442 })
9443 .collect();
9444 frames.push(Frame::PushCommit {
9445 client_commit_id: commit.client_commit_id.clone(),
9446 operations,
9447 });
9448 pushed_ids.push(commit.client_commit_id.clone());
9449 }
9450 #[cfg(feature = "bench-internals")]
9451 drop(outbox_phase);
9452 let accept = self.limits.accept.unwrap_or(if url_capable {
9455 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
9456 } else {
9457 DEFAULT_ACCEPT
9458 });
9459 frames.push(Frame::PullHeader {
9460 limit_commits: self.limits.limit_commits.unwrap_or(0),
9461 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
9462 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
9463 accept,
9464 });
9465 let mut fresh = Vec::new();
9466 for sub in &self.subs {
9467 if sub.state != SubState::Active {
9468 continue;
9469 }
9470 let mut scopes = sub.requested.clone();
9471 sort_scope_map(&mut scopes);
9472 frames.push(Frame::Subscription {
9473 id: sub.id.clone(),
9474 table: sub.table.clone(),
9475 scopes,
9476 params: sub.params.clone().map(RawJson),
9477 cursor: sub.cursor,
9478 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
9479 });
9480 fresh.push((
9481 sub.id.clone(),
9482 sub.cursor < 0 && sub.bootstrap_state.is_none(),
9483 ));
9484 }
9485 let message = Message {
9486 wire_version: WIRE_VERSION,
9487 msg_kind: MsgKind::Request,
9488 frames,
9489 };
9490 (
9491 message,
9492 RequestMeta {
9493 pushed_ids,
9494 fresh,
9495 accept,
9496 deferred_commits,
9497 },
9498 )
9499 }
9500
9501 pub fn progress(&self) -> ProgressObserver {
9505 self.progress.clone()
9506 }
9507
9508 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9509 self.storage_failure.borrow_mut().take();
9510 self.progress.start();
9511 self.round_retry_delay_ms = None;
9512 let started_at_ms = self.clock_now_ms();
9513 let mut outcome = self.sync_inner(transport);
9514 if let SyncOutcome::Failed {
9515 error_code,
9516 message,
9517 details,
9518 } = &mut outcome
9519 {
9520 if let Some(failure) = self.storage_failure.borrow().as_ref() {
9521 let code = failure
9522 .code
9523 .expect("retained storage failures are classified");
9524 *error_code = code.to_owned();
9525 *message = match code {
9526 "client.storage_full" => "local SQLite storage is full",
9527 "client.storage_io" => "local SQLite storage I/O failed",
9528 "client.storage_corrupt" => "local SQLite storage is corrupt",
9529 _ => unreachable!("unrecognized classified storage failure"),
9530 }
9531 .to_owned();
9532 *details = failure.details();
9533 }
9534 }
9535 let retry_delay_ms = self.round_retry_delay_ms;
9536 self.progress.update(|p| match &outcome {
9537 SyncOutcome::Ok(report) if report.failed.is_empty() => {
9538 p.state = ProgressState::Complete
9539 }
9540 SyncOutcome::Ok(_) => {
9541 p.state = ProgressState::Failed;
9542 p.error_code = Some("sync.scope_revoked".into());
9543 }
9544 SyncOutcome::Failed { error_code, .. } => {
9545 p.state = ProgressState::Failed;
9546 p.error_code = Some(Self::diagnostic_code(error_code));
9547 p.retry_delay_ms = retry_delay_ms;
9548 }
9549 SyncOutcome::RealtimeUnavailable { .. } => {
9550 p.state = ProgressState::Failed;
9551 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
9552 p.retry_delay_ms = retry_delay_ms;
9553 }
9554 });
9555 let completed_at_ms = self.clock_now_ms();
9556 self.last_round = Some(match &outcome {
9557 SyncOutcome::Ok(report) => DiagnosticLastRound {
9558 status: "succeeded".to_owned(),
9559 started_at_ms,
9560 completed_at_ms,
9561 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9562 counters: Some(DiagnosticRoundCounters {
9563 pushed: report.pushed,
9564 applied: report.applied.len(),
9565 rejected: report.rejected.len(),
9566 retryable: report.retryable.len(),
9567 conflicts: report.conflicts,
9568 commits_applied: report.commits_applied,
9569 segment_rows_applied: report.segment_rows_applied,
9570 bootstrapping: report.bootstrapping.len(),
9571 resets: report.resets.len(),
9572 revoked: report.revoked.len(),
9573 failed: report.failed.len(),
9574 deferred_commits: report.deferred_commits,
9575 }),
9576 error_code: None,
9577 },
9578 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
9579 status: "failed".to_owned(),
9580 started_at_ms,
9581 completed_at_ms,
9582 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9583 counters: None,
9584 error_code: Some(Self::diagnostic_code(error_code)),
9585 },
9586 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
9587 status: "failed".to_owned(),
9588 started_at_ms,
9589 completed_at_ms,
9590 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9591 counters: None,
9592 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
9593 },
9594 });
9595 outcome
9596 }
9597
9598 fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9599 if self.stopped {
9600 return SyncOutcome::Ok(SyncReport {
9603 schema_floor: self.schema_floor.clone(),
9604 ..SyncReport::default()
9605 });
9606 }
9607 self.set_sync_needed(false, false);
9610 if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
9613 if let Err(TransportError { code, message, .. }) = self.flush_blob_uploads(transport) {
9614 if Self::retryable_failure_code(&code) {
9615 self.schedule_background_retry();
9616 }
9617 return SyncOutcome::Failed {
9618 details: None,
9619 error_code: code,
9620 message,
9621 };
9622 }
9623 }
9624 if let Err(message) = self.drain_pending_evictions() {
9625 return SyncOutcome::Failed {
9626 details: None,
9627 error_code: "storage.failed".into(),
9628 message,
9629 };
9630 }
9631 if let Err(message) = self.drop_unencodable_outbox() {
9635 return SyncOutcome::Failed {
9636 details: None,
9637 error_code: "storage.failed".into(),
9638 message,
9639 };
9640 }
9641 let (message, meta) = self.build_request(transport.supports_url_fetch());
9642 let request_bytes = {
9643 #[cfg(feature = "bench-internals")]
9644 let _phase = self.benchmark_phases.start(Phase::RequestEncode);
9645 encode_message(&message)
9646 };
9647 let round = if self.realtime_state == RealtimeState::Connected {
9654 transport.realtime_sync(&request_bytes)
9655 } else if self.realtime_policy == RealtimePolicy::Required {
9656 let retry_delay_ms = self.schedule_background_retry();
9657 self.realtime_retry_delay_ms = Some(retry_delay_ms);
9658 return SyncOutcome::RealtimeUnavailable {
9659 state: self.realtime_state,
9660 reason_code: self.realtime_reason_code.clone(),
9661 retry_delay_ms,
9662 };
9663 } else {
9664 transport.sync(&request_bytes)
9665 };
9666 let realtime_round = self.realtime_state == RealtimeState::Connected;
9667 let response_bytes = match round {
9668 Ok(bytes) => bytes,
9669 Err(TransportError { code, message, .. }) => {
9670 if realtime_round {
9674 self.set_realtime_state(RealtimeState::Lost, Some(&code));
9675 }
9676 self.record_lease_error(&code);
9679 if Self::retryable_failure_code(&code) {
9680 let retry_delay_ms = self.schedule_background_retry();
9681 if realtime_round {
9682 self.realtime_retry_delay_ms = Some(retry_delay_ms);
9683 }
9684 }
9685 return SyncOutcome::Failed {
9686 details: None,
9687 error_code: code,
9688 message,
9689 };
9690 }
9691 };
9692 #[cfg(feature = "bench-internals")]
9693 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
9694 let response = match decode_message(&response_bytes) {
9695 Ok(message) => message,
9696 Err(error) => {
9697 return SyncOutcome::Failed {
9700 details: None,
9701 error_code: error.code.as_str().to_owned(),
9702 message: error.detail,
9703 };
9704 }
9705 };
9706 #[cfg(feature = "bench-internals")]
9707 drop(decode_phase);
9708 if response.msg_kind != MsgKind::Response {
9709 return SyncOutcome::Failed {
9710 details: None,
9711 error_code: "sync.invalid_request".to_owned(),
9712 message: "expected a response message".to_owned(),
9713 };
9714 }
9715 let mut outcome = self.process_response(transport, response, &meta);
9716 if let SyncOutcome::Ok(report) = &mut outcome {
9717 report.deferred_commits = meta.deferred_commits;
9718 }
9719 match &outcome {
9720 SyncOutcome::Ok(_) => self.reset_background_retry(),
9721 SyncOutcome::Failed { error_code, .. } if Self::retryable_failure_code(error_code) => {
9722 self.schedule_background_retry();
9723 }
9724 SyncOutcome::Failed { .. } => {}
9725 SyncOutcome::RealtimeUnavailable { .. } => {}
9726 }
9727 if meta.deferred_commits > 0 {
9728 self.set_sync_needed(true, true);
9731 }
9732 self.previous_version_lifetime_check();
9735 outcome
9736 }
9737
9738 pub fn sync_until_idle(
9739 &mut self,
9740 transport: &mut dyn Transport,
9741 max_rounds: Option<u32>,
9742 ) -> SyncOutcome {
9743 let rounds = max_rounds.unwrap_or(20).max(1);
9744 let mut aggregate = SyncReport::default();
9745 let mut spent = 0;
9746 while spent < rounds {
9747 spent += 1;
9748 let before = self
9749 .subs
9750 .iter()
9751 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9752 .collect::<Vec<_>>();
9753 let outcome = self.sync(transport);
9754 match outcome {
9755 SyncOutcome::Failed {
9756 error_code,
9757 message,
9758 details,
9759 } => {
9760 return SyncOutcome::Failed {
9761 details,
9762 error_code,
9763 message,
9764 };
9765 }
9766 SyncOutcome::RealtimeUnavailable { .. } => return outcome,
9767 SyncOutcome::Ok(report) => {
9768 if max_rounds.is_none()
9769 && report.segment_rows_applied > 0
9770 && !report.bootstrapping.is_empty()
9771 && before
9772 != self
9773 .subs
9774 .iter()
9775 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9776 .collect::<Vec<_>>()
9777 {
9778 spent = 0;
9779 }
9780 aggregate.pushed += report.pushed;
9781 aggregate.applied.extend(report.applied.iter().cloned());
9782 aggregate.rejected.extend(report.rejected.iter().cloned());
9783 aggregate.retryable.extend(report.retryable.iter().cloned());
9784 aggregate.conflicts += report.conflicts;
9785 aggregate.commits_applied += report.commits_applied;
9786 aggregate.segment_rows_applied += report.segment_rows_applied;
9787 aggregate.bootstrapping = report.bootstrapping.clone();
9788 aggregate.resets.extend(report.resets.iter().cloned());
9789 aggregate.revoked.extend(report.revoked.iter().cloned());
9790 aggregate.failed.extend(report.failed.iter().cloned());
9791 aggregate.deferred_commits = report.deferred_commits;
9792 if report.schema_floor.is_some() {
9793 aggregate.schema_floor = report.schema_floor.clone();
9794 }
9795 let more = !report.bootstrapping.is_empty()
9801 || report.commits_applied > 0
9802 || report.segment_rows_applied > 0
9803 || !report.resets.is_empty()
9804 || self.sync_needed;
9805 if !more {
9806 return SyncOutcome::Ok(aggregate);
9807 }
9808 }
9809 }
9810 }
9811 SyncOutcome::Failed {
9812 details: None,
9813 error_code: "sync.invalid_request".into(),
9814 message: "sync did not reach idle within the round budget".into(),
9815 }
9816 }
9817
9818 fn process_response(
9819 &mut self,
9820 transport: &mut dyn Transport,
9821 response: Message,
9822 meta: &RequestMeta,
9823 ) -> SyncOutcome {
9824 #[cfg(feature = "bench-internals")]
9825 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
9826 let mut report = SyncReport {
9827 pushed: meta.pushed_ids.len() as u32,
9828 ..SyncReport::default()
9829 };
9830 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
9831 HashMap::new();
9832 let pushed_ids = meta
9833 .pushed_ids
9834 .iter()
9835 .map(String::as_str)
9836 .collect::<HashSet<_>>();
9837 let mut last_final_push_result_id: Option<String> = None;
9838 for frame in &response.frames {
9839 match frame {
9840 Frame::PushResultDetails {
9841 client_commit_id,
9842 entries,
9843 } => {
9844 let details = rejection_details_by_commit
9845 .entry(client_commit_id.clone())
9846 .or_default();
9847 for entry in entries {
9848 let parsed = match RejectionDetails::parse(&entry.details.0) {
9849 Ok(value) => value,
9850 Err(message) => {
9851 return SyncOutcome::Failed {
9852 details: None,
9853 error_code: "sync.invalid_request".to_owned(),
9854 message,
9855 };
9856 }
9857 };
9858 details.insert(entry.op_index, parsed);
9859 }
9860 }
9861 Frame::PushResult {
9862 client_commit_id,
9863 status,
9864 results,
9865 ..
9866 } if pushed_ids.contains(client_commit_id.as_str())
9867 && Self::push_result_is_final(*status, results) =>
9868 {
9869 last_final_push_result_id = Some(client_commit_id.clone());
9870 }
9871 _ => {}
9872 }
9873 }
9874 let mut frames = response.frames.into_iter().peekable();
9875 if response.wire_version < 2 {
9876 return SyncOutcome::Failed {
9877 details: None,
9878 error_code: "client.invalid_host_response".to_owned(),
9879 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
9880 };
9881 }
9882 match frames.next() {
9883 Some(Frame::RespHeader {
9884 required_schema_version,
9885 latest_schema_version,
9886 log_epoch,
9887 reset_required,
9888 }) => {
9889 let Some(log_epoch) = log_epoch else {
9890 return SyncOutcome::Failed {
9891 details: None,
9892 error_code: "client.invalid_host_response".to_owned(),
9893 message: "response header omits logEpoch".to_owned(),
9894 };
9895 };
9896 let Some(reset_required) = reset_required else {
9897 return SyncOutcome::Failed {
9898 details: None,
9899 error_code: "client.invalid_host_response".to_owned(),
9900 message: "response header omits resetRequired".to_owned(),
9901 };
9902 };
9903 if let Some(required) = required_schema_version {
9904 let floor = SchemaFloor {
9907 required_schema_version: Some(required),
9908 latest_schema_version,
9909 };
9910 self.set_schema_floor(Some(floor.clone()));
9911 report.schema_floor = Some(floor);
9912 return SyncOutcome::Ok(report);
9913 }
9914 if reset_required {
9915 if frames.next().is_some() {
9916 return SyncOutcome::Failed {
9917 details: None,
9918 error_code: "client.invalid_host_response".to_owned(),
9919 message: "log-epoch reset response contains body frames".to_owned(),
9920 };
9921 }
9922 match self.run_log_epoch_reset(&log_epoch) {
9923 Ok(resets) => {
9924 report.resets = resets;
9925 return SyncOutcome::Ok(report);
9926 }
9927 Err(message) => {
9928 return SyncOutcome::Failed {
9929 details: None,
9930 error_code: "sync.local_corrupt".to_owned(),
9931 message,
9932 };
9933 }
9934 }
9935 }
9936 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
9937 return SyncOutcome::Failed {
9938 details: None,
9939 error_code: "client.invalid_host_response".to_owned(),
9940 message: "server changed logEpoch without requiring a reset".to_owned(),
9941 };
9942 }
9943 }
9944 _ => {
9945 return SyncOutcome::Failed {
9946 details: None,
9947 error_code: "sync.invalid_request".to_owned(),
9948 message: "response does not start with RESP_HEADER".to_owned(),
9949 };
9950 }
9951 }
9952
9953 let mut failure: Option<(String, String)> = None;
9954 while let Some(frame) = frames.next() {
9955 match frame {
9956 frame @ Frame::PushResult { .. } => {
9957 let successful = matches!(
9958 &frame,
9959 Frame::PushResult {
9960 status: PushStatus::Applied | PushStatus::Cached,
9961 ..
9962 }
9963 );
9964 let mut results = vec![frame];
9965 if successful {
9966 while matches!(
9967 frames.peek(),
9968 Some(Frame::PushResult {
9969 status: PushStatus::Applied | PushStatus::Cached,
9970 ..
9971 })
9972 ) {
9973 if let Some(next) = frames.next() {
9974 results.push(next);
9975 }
9976 }
9977 }
9978 if self
9979 .handle_push_results(
9980 &results,
9981 &pushed_ids,
9982 &rejection_details_by_commit,
9983 &mut report,
9984 last_final_push_result_id.as_deref(),
9985 )
9986 .is_err()
9987 {
9988 failure = Some((
9989 "client.outcome_persistence_failed".to_owned(),
9990 "local commit outcome could not be persisted".to_owned(),
9991 ));
9992 break;
9993 }
9994 }
9995 Frame::PushResultDetails { .. } => {}
9996 Frame::SubStart {
9997 id,
9998 status,
9999 reason_code,
10000 effective_scopes,
10001 bootstrap: _,
10002 } => {
10003 let mut body = Vec::new();
10004 let mut sub_end: Option<(i64, Option<String>)> = None;
10005 for inner in frames.by_ref() {
10006 match inner {
10007 Frame::SubEnd {
10008 next_cursor,
10009 bootstrap_state,
10010 } => {
10011 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
10012 break;
10013 }
10014 Frame::Unknown { .. } => {}
10015 other => body.push(other),
10016 }
10017 }
10018 if let Err(SectionError::Abort(code, message)) = self.process_section(
10019 transport,
10020 &id,
10021 status,
10022 &reason_code,
10023 effective_scopes,
10024 body,
10025 sub_end,
10026 meta,
10027 &mut report,
10028 ) {
10029 failure = Some((code, message));
10030 break;
10031 }
10032 }
10033 Frame::Lease {
10034 lease_id,
10035 expires_at_ms,
10036 } => {
10037 self.set_lease_state(Some(LeaseState {
10040 lease_id: Some(lease_id),
10041 expires_at_ms: Some(expires_at_ms),
10042 error_code: None,
10043 }));
10044 }
10045 Frame::Error { code, message, .. } => {
10046 failure = Some((code, message));
10049 break;
10050 }
10051 Frame::Unknown { .. } => {}
10052 _ => {
10053 failure = Some((
10054 "sync.invalid_request".to_owned(),
10055 "unexpected frame in response".to_owned(),
10056 ));
10057 break;
10058 }
10059 }
10060 }
10061
10062 if self.overlay_dirty.get() {
10067 self.rebuild_overlay();
10068 }
10069 if let Some((error_code, message)) = failure {
10070 return SyncOutcome::Failed {
10071 details: None,
10072 error_code,
10073 message,
10074 };
10075 }
10076 if let Err(message) = self.drain_pending_evictions() {
10079 return SyncOutcome::Failed {
10080 details: None,
10081 error_code: "storage.failed".into(),
10082 message,
10083 };
10084 }
10085 self.ack_after_pull(transport);
10086 if self.upgrading && report.bootstrapping.is_empty() {
10089 self.set_upgrading(false);
10090 }
10091 SyncOutcome::Ok(report)
10092 }
10093
10094 fn handle_push_results(
10097 &mut self,
10098 frames: &[Frame],
10099 pushed_ids: &HashSet<&str>,
10100 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
10101 report: &mut SyncReport,
10102 last_final_push_result_id: Option<&str>,
10103 ) -> Result<(), String> {
10104 self.begin_observation("syncular_push_results")?;
10105 let previous_failed = self.failed_commits.clone();
10106 let conflict_count = self.conflicts.len();
10107 let rejection_count = self.rejections.len();
10108 let applied_count = report.applied.len();
10109 let rejected_count = report.rejected.len();
10110 let retryable_count = report.retryable.len();
10111 let was_dirty = self.overlay_dirty.get();
10112 let mut removed = Vec::new();
10113 let mut batch = ChangeAccumulator::default();
10114 let persisted = (|| {
10115 for frame in frames {
10116 let Frame::PushResult {
10117 client_commit_id,
10118 status,
10119 results,
10120 ..
10121 } = frame
10122 else {
10123 return Err("unexpected acknowledgement frame".to_owned());
10124 };
10125 if !pushed_ids.contains(client_commit_id.as_str()) {
10126 continue;
10127 }
10128 let Some(index) = self
10129 .outbox
10130 .iter()
10131 .position(|commit| commit.client_commit_id == *client_commit_id)
10132 else {
10133 continue;
10134 };
10135 let status = *status;
10136 let rejection_details = rejection_details_by_commit.get(client_commit_id);
10137 let operations = self.outbox[index].ops.clone();
10138 match status {
10139 PushStatus::Applied | PushStatus::Cached => {
10140 let journal_results = results
10143 .iter()
10144 .map(|result| {
10145 let op_index = match result {
10146 OpResult::Applied { op_index }
10147 | OpResult::Conflict { op_index, .. }
10148 | OpResult::Error { op_index, .. } => *op_index,
10149 };
10150 CommitOperationOutcome::Applied { op_index }
10151 })
10152 .collect::<Vec<_>>();
10153 let outcome_status = if status == PushStatus::Applied {
10154 CommitOutcomeStatus::Applied
10155 } else {
10156 CommitOutcomeStatus::Cached
10157 };
10158 let persisted = self
10159 .persist_commit_outcome(
10160 client_commit_id,
10161 outcome_status,
10162 &journal_results,
10163 None,
10164 )
10165 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
10166 persisted?;
10167 self.overlay_dirty.set(true);
10168 batch.status = true;
10169 batch.outcomes = true;
10170 }
10171 PushStatus::Rejected => {
10172 if results.iter().any(|result| {
10173 matches!(
10174 result,
10175 OpResult::Error {
10176 code,
10177 retryable: true,
10178 ..
10179 } if code == "sync.idempotency_cache_miss"
10180 )
10181 }) {
10182 report.retryable.push(client_commit_id.to_owned());
10185 continue;
10186 }
10187
10188 let mut journal_results = Vec::with_capacity(results.len());
10189 let mut conflicts = Vec::new();
10190 let mut rejections = Vec::new();
10191 for result in results {
10192 match result {
10193 OpResult::Applied { op_index } => {
10194 journal_results.push(CommitOperationOutcome::Applied {
10195 op_index: *op_index,
10196 });
10197 }
10198 OpResult::Conflict {
10199 op_index,
10200 code,
10201 message,
10202 server_version,
10203 server_row,
10204 conflict_columns: conflict_columns_bitmap,
10205 } => {
10206 let operation = operations
10207 .get(*op_index as usize)
10208 .map(CommitOperation::from);
10209 let (table, row_id) = operation
10210 .as_ref()
10211 .map(|op| (op.table.clone(), op.row_id.clone()))
10212 .unwrap_or_default();
10213 let server_row_json = self
10214 .schema
10215 .table(&table)
10216 .and_then(|t| {
10217 decode_row_bytes(t, server_row, &self.encryption)
10218 .ok()
10219 .map(|row| (t, row))
10220 })
10221 .map(|(t, row)| {
10222 let mut map = Map::new();
10223 for (i, column) in t.columns.iter().enumerate() {
10224 map.insert(
10225 column.name.clone(),
10226 column_value_to_json(
10227 row.get(i).unwrap_or(&None),
10228 ),
10229 );
10230 }
10231 map
10232 })
10233 .unwrap_or_default();
10234 let conflict_columns = self
10237 .schema
10238 .table(&table)
10239 .map(|t| {
10240 t.columns
10241 .iter()
10242 .enumerate()
10243 .filter(|(index, _)| {
10244 conflict_columns_bitmap
10245 .get(index / 8)
10246 .is_some_and(|byte| {
10247 byte & (1 << (index % 8)) != 0
10248 })
10249 })
10250 .map(|(_, column)| column.name.clone())
10251 .collect::<Vec<_>>()
10252 })
10253 .unwrap_or_default();
10254 let conflict = ConflictRecord {
10255 client_commit_id: client_commit_id.to_owned(),
10256 op_index: *op_index,
10257 table,
10258 row_id,
10259 code: code.clone(),
10260 message: message.clone(),
10261 server_version: *server_version,
10262 server_row: server_row_json,
10263 conflict_columns,
10264 operation,
10265 };
10266 journal_results.push(CommitOperationOutcome::Conflict {
10267 conflict: conflict.clone(),
10268 });
10269 conflicts.push(conflict);
10270 }
10271 OpResult::Error {
10272 op_index,
10273 code,
10274 message,
10275 retryable,
10276 } => {
10277 let rejection = RejectionRecord {
10278 client_commit_id: client_commit_id.to_owned(),
10279 op_index: *op_index,
10280 code: code.clone(),
10281 message: message.clone(),
10282 retryable: *retryable,
10283 details: rejection_details
10284 .and_then(|details| details.get(op_index))
10285 .cloned(),
10286 operation: operations
10287 .get(*op_index as usize)
10288 .map(CommitOperation::from),
10289 };
10290 journal_results.push(CommitOperationOutcome::Error {
10291 rejection: rejection.clone(),
10292 });
10293 rejections.push(rejection);
10294 }
10295 }
10296 }
10297 if self.retain_failed_commits {
10298 self.retain_failed_commit(client_commit_id, &operations)?;
10299 }
10300 let outcome_status = if conflicts.is_empty() {
10301 CommitOutcomeStatus::Rejected
10302 } else {
10303 CommitOutcomeStatus::Conflict
10304 };
10305 let persisted = self
10306 .persist_commit_outcome(
10307 client_commit_id,
10308 outcome_status,
10309 &journal_results,
10310 Some(&operations),
10311 )
10312 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
10313 persisted?;
10314 batch.conflicts = !conflicts.is_empty();
10315 batch.rejections = !rejections.is_empty();
10316 batch.status = true;
10317 batch.outcomes = true;
10318 self.conflicts.extend(conflicts);
10319 self.rejections.extend(rejections);
10320 self.overlay_dirty.set(true);
10321 }
10322 }
10323 removed.push((index, self.outbox.remove(index)));
10324 if batch.status {
10325 for operation in &operations {
10326 if !self.record_row_scopes(
10327 &mut batch,
10328 &operation.table,
10329 &operation.row_id,
10330 false,
10331 ) {
10332 batch.table(&operation.table);
10333 }
10334 }
10335 }
10336 match status {
10337 PushStatus::Applied | PushStatus::Cached => {
10338 report.applied.push(client_commit_id.to_owned())
10339 }
10340 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
10341 }
10342 }
10343 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)) {
10344 self.prune_commit_outcomes()?;
10345 }
10346 self.finish_observation("syncular_push_results", batch)
10347 })();
10348 if let Err(error) = persisted {
10349 self.rollback_observation("syncular_push_results");
10350 self.failed_commits = previous_failed;
10351 for (index, commit) in removed.into_iter().rev() {
10352 self.outbox.insert(index, commit);
10353 }
10354 self.conflicts.truncate(conflict_count);
10355 self.rejections.truncate(rejection_count);
10356 self.overlay_dirty.set(was_dirty);
10357 report.applied.truncate(applied_count);
10358 report.rejected.truncate(rejected_count);
10359 report.retryable.truncate(retryable_count);
10360 return Err(error);
10361 }
10362 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
10363 Ok(())
10364 }
10365
10366 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
10367 status != PushStatus::Rejected
10368 || !results.iter().any(|result| {
10369 matches!(
10370 result,
10371 OpResult::Error {
10372 code,
10373 retryable: true,
10374 ..
10375 } if code == "sync.idempotency_cache_miss"
10376 )
10377 })
10378 }
10379
10380 #[allow(clippy::too_many_arguments)]
10383 fn process_section(
10384 &mut self,
10385 transport: &mut dyn Transport,
10386 id: &str,
10387 status: SubStatus,
10388 reason_code: &str,
10389 effective_scopes: Vec<(String, Vec<String>)>,
10390 body: Vec<Frame>,
10391 sub_end: Option<(i64, Option<String>)>,
10392 meta: &RequestMeta,
10393 report: &mut SyncReport,
10394 ) -> Result<(), SectionError> {
10395 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
10396 return Ok(()); };
10398 if status != SubStatus::Active && sub_end.is_none() {
10399 return Err(SectionError::Abort(
10400 "sync.invalid_request".into(),
10401 "subscription section without SUB_END".into(),
10402 ));
10403 }
10404 match status {
10405 SubStatus::Revoked => {
10406 self.begin_observation("syncular_revocation")
10407 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
10408 let previous = self.subs[sub_index].clone();
10409 let was_dirty = self.overlay_dirty.get();
10410 let previous_outbox = self.outbox.clone();
10411 let previous_failed = self.failed_commits.clone();
10412 let rejection_count = self.rejections.len();
10413 let revoked_count = report.revoked.len();
10414 let failed_count = report.failed.len();
10415 let result =
10416 (|| {
10417 let mut batch = ChangeAccumulator::default();
10418 let registered = self.window_unit_by_sub_id(id);
10419 let (table, effective) = {
10421 let sub = &self.subs[sub_index];
10422 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10423 };
10424 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
10425 SectionError::Abort(
10426 "sync.unknown_table".to_owned(),
10427 "revoked scope references an unknown table".to_owned(),
10428 )
10429 })?;
10430 let dropped = self
10431 .drop_failed_commits(|name, values| {
10432 name == table
10433 && !effective.is_empty()
10434 && effective.iter().all(|(variable, allowed)| {
10435 scoped_table
10436 .scope_column(variable)
10437 .and_then(|column| values.get(column))
10438 .and_then(Value::as_str)
10439 .is_some_and(|value| {
10440 allowed.iter().any(|allowed| allowed == value)
10441 })
10442 })
10443 })
10444 .map_err(|message| {
10445 SectionError::Abort("storage.failed".to_owned(), message)
10446 })?;
10447 for commit in dropped {
10448 batch.outcomes = true;
10449 for operation in commit.ops {
10450 batch.table(&operation.table);
10451 }
10452 }
10453 let purged = self.purge_scope_rows(&table, &effective);
10454 match purged {
10455 Ok(()) => {
10456 self.record_scope_map(&mut batch, &table, &effective);
10457 let sub = &mut self.subs[sub_index];
10458 sub.state = SubState::Revoked;
10459 sub.reason_code = Some(if reason_code.is_empty() {
10460 "sync.scope_revoked".to_owned()
10461 } else {
10462 reason_code.to_owned()
10463 });
10464 report.revoked.push(id.to_owned());
10465 let doomed_effective = effective;
10466 let sub_table = table;
10467 self.persist_sub(&self.subs[sub_index].clone()).map_err(
10468 |message| SectionError::Abort("storage.failed".into(), message),
10469 )?;
10470 let dropped = self
10471 .drop_doomed_outbox(&sub_table, &doomed_effective)
10472 .map_err(|message| {
10473 SectionError::Abort("storage.failed".to_owned(), message)
10474 })?;
10475 if dropped {
10476 batch.status = true;
10477 batch.rejections = true;
10478 batch.outcomes = true;
10479 }
10480 self.delete_unreferenced_cached_blobs();
10483 }
10484 Err(()) => {
10485 let sub = &mut self.subs[sub_index];
10488 sub.state = SubState::Failed;
10489 sub.reason_code = Some("sync.scope_revoked".to_owned());
10490 report.failed.push(id.to_owned());
10491 self.persist_sub(&self.subs[sub_index].clone()).map_err(
10492 |message| SectionError::Abort("storage.failed".into(), message),
10493 )?;
10494 }
10495 }
10496 if let Some((base_key, unit)) = registered {
10497 batch.window(&base_key, &self.subs[sub_index].table, &unit);
10498 }
10499 self.rebuild_overlay_if_dirty();
10500 self.finish_observation("syncular_revocation", batch)
10501 .map_err(|message| {
10502 SectionError::Abort("storage.failed".to_owned(), message)
10503 })?;
10504 Ok(())
10505 })();
10506 if result.is_err() {
10507 self.rollback_observation("syncular_revocation");
10508 self.subs[sub_index] = previous;
10509 self.overlay_dirty.set(was_dirty);
10510 self.outbox = previous_outbox;
10511 self.failed_commits = previous_failed;
10512 self.rejections.truncate(rejection_count);
10513 report.revoked.truncate(revoked_count);
10514 report.failed.truncate(failed_count);
10515 }
10516 result
10517 }
10518 SubStatus::Reset => {
10519 self.begin_observation("syncular_reset")
10520 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
10521 let previous = self.subs[sub_index].clone();
10522 let was_dirty = self.overlay_dirty.get();
10523 let reset_count = report.resets.len();
10524 let result = (|| {
10525 let mut batch = ChangeAccumulator::default();
10526 let registered = self.window_unit_by_sub_id(id);
10527 let sub = &mut self.subs[sub_index];
10530 sub.cursor = -1;
10531 sub.bootstrap_state = None;
10532 report.resets.push(id.to_owned());
10533 self.persist_sub(&self.subs[sub_index].clone())
10534 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10535 if let Some((base_key, unit)) = registered {
10536 batch.window(&base_key, &self.subs[sub_index].table, &unit);
10537 }
10538 self.finish_observation("syncular_reset", batch)
10539 .map_err(|message| {
10540 SectionError::Abort("storage.failed".to_owned(), message)
10541 })?;
10542 Ok(())
10543 })();
10544 if result.is_err() {
10545 self.rollback_observation("syncular_reset");
10546 self.subs[sub_index] = previous;
10547 self.overlay_dirty.set(was_dirty);
10548 report.resets.truncate(reset_count);
10549 }
10550 result
10551 }
10552 SubStatus::Active => {
10553 let fresh = meta
10554 .fresh
10555 .iter()
10556 .find(|(fid, _)| fid == id)
10557 .map(|(_, fresh)| *fresh)
10558 .unwrap_or(false);
10559 let previous = self.subs[sub_index].clone();
10560 self.subs[sub_index].effective = Some(effective_scopes);
10561 let outcome = self
10562 .apply_section_body(transport, sub_index, body, fresh, meta, report)
10563 .and_then(|()| {
10564 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
10565 SectionError::Abort(
10566 "sync.invalid_request".into(),
10567 "subscription section without SUB_END".into(),
10568 )
10569 })?;
10570 self.apply_sub_end(sub_index, cursor, bootstrap_state)
10571 .map_err(|message| {
10572 SectionError::Abort("storage.failed".into(), message)
10573 })
10574 });
10575 match outcome {
10576 Ok(()) => {
10577 if self.subs[sub_index].bootstrap_state.is_some() {
10578 report.bootstrapping.push(id.to_owned());
10579 }
10580 Ok(())
10581 }
10582 Err(SectionError::FailClosed) => {
10583 self.subs[sub_index] = previous.clone();
10584 self.begin_observation("syncular_section_failure")
10585 .map_err(|message| {
10586 SectionError::Abort("storage.failed".into(), message)
10587 })?;
10588 let persisted = (|| {
10589 let mut batch = ChangeAccumulator::default();
10590 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
10591 batch.window(&base_key, &self.subs[sub_index].table, &unit);
10592 }
10593 self.subs[sub_index].state = SubState::Failed;
10594 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
10595 self.persist_sub(&self.subs[sub_index])?;
10596 self.finish_observation("syncular_section_failure", batch)
10597 })();
10598 if let Err(message) = persisted {
10599 self.rollback_observation("syncular_section_failure");
10600 self.subs[sub_index] = previous;
10601 return Err(SectionError::Abort("storage.failed".into(), message));
10602 }
10603 report.failed.push(id.to_owned());
10604 Ok(())
10605 }
10606 Err(error) => {
10607 self.subs[sub_index] = previous;
10610 Err(error)
10611 }
10612 }
10613 }
10614 }
10615 }
10616
10617 fn apply_sub_end(
10618 &mut self,
10619 sub_index: usize,
10620 cursor: i64,
10621 bootstrap_state: Option<String>,
10622 ) -> Result<(), String> {
10623 #[cfg(feature = "bench-internals")]
10624 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
10625 let previous = self.subs[sub_index].clone();
10626 self.begin_observation("syncular_sub_end")?;
10627 let persisted = (|| {
10628 let mut batch = ChangeAccumulator::default();
10629 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
10630 && cursor >= 0
10631 && bootstrap_state.is_none();
10632 if completed {
10633 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
10634 batch.window(&base_key, &previous.table, &unit);
10635 }
10636 }
10637 let sub = &mut self.subs[sub_index];
10638 sub.cursor = cursor;
10639 sub.bootstrap_state = bootstrap_state;
10640 sub.synced_once = true;
10641 self.persist_sub(&self.subs[sub_index])?;
10642 self.finish_observation("syncular_sub_end", batch)
10643 })();
10644 if persisted.is_err() {
10645 self.rollback_observation("syncular_sub_end");
10646 self.subs[sub_index] = previous;
10647 }
10648 persisted
10649 }
10650
10651 fn apply_section_body(
10652 &mut self,
10653 transport: &mut dyn Transport,
10654 sub_index: usize,
10655 body: Vec<Frame>,
10656 fresh: bool,
10657 meta: &RequestMeta,
10658 report: &mut SyncReport,
10659 ) -> Result<(), SectionError> {
10660 let mut saw_segment = false;
10661 for frame in body {
10662 match frame {
10663 Frame::Commit {
10664 tables, changes, ..
10665 } => {
10666 self.apply_commit_frame(&tables, &changes)
10667 .map_err(|(c, m)| SectionError::Abort(c, m))?;
10668 report.commits_applied += 1;
10669 }
10670 Frame::SegmentInline { payload } => {
10671 let segment = decode_rows_segment(&payload)
10672 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
10673 self.progress.update(|p| {
10674 p.phase = ProgressPhase::Import;
10675 p.subscription_id = Some(self.subs[sub_index].id.clone());
10676 p.table = Some(self.subs[sub_index].table.clone());
10677 p.segment_id = None;
10678 p.bytes_received = payload.len() as u64;
10679 p.bytes_total = Some(payload.len() as u64);
10680 p.rows_processed = 0;
10681 p.rows_total =
10682 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
10683 });
10684 let first = !saw_segment;
10685 saw_segment = true;
10686 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10687 report.segment_rows_applied += applied;
10688 }
10689 Frame::SegmentRef {
10690 segment_id,
10691 byte_length,
10692 media_type,
10693 table,
10694 row_count,
10695 as_of_commit_seq,
10696 scope_digest,
10697 row_cursor,
10698 next_row_cursor,
10699 url,
10700 url_expires_at_ms,
10701 ..
10702 } => {
10703 let advertised = match media_type {
10706 MediaType::Rows => {
10707 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
10708 || meta.accept & ACCEPT_INLINE_ROWS != 0
10709 }
10710 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
10711 };
10712 if !advertised {
10713 return Err(SectionError::Abort(
10714 "sync.invalid_request".to_owned(),
10715 format!(
10716 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
10717 media_type.name()
10718 ),
10719 ));
10720 }
10721 self.progress.update(|p| {
10722 p.phase = ProgressPhase::Download;
10723 p.subscription_id = Some(self.subs[sub_index].id.clone());
10724 p.table = Some(table.clone());
10725 p.segment_id = Some(segment_id.clone());
10726 p.bytes_received = 0;
10727 p.bytes_total = u64::try_from(byte_length).ok();
10728 p.rows_processed = 0;
10729 p.rows_total = u64::try_from(row_count).ok();
10730 });
10731 let observer = self.progress.clone();
10732 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
10733 let bytes = if let Some(url) = url {
10734 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
10739 return Err(SectionError::Abort(
10740 "sync.invalid_request".to_owned(),
10741 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
10742 .to_owned(),
10743 ));
10744 }
10745 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
10747 return Err(SectionError::Abort(
10748 "sync.segment_expired".to_owned(),
10749 format!(
10750 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
10751 ),
10752 ));
10753 }
10754 transport
10755 .fetch_url(&url, &mut on_progress)
10756 .map_err(|e| SectionError::Abort(e.code, e.message))?
10757 } else {
10758 let requested_scopes_json =
10759 canonical_scope_json(&self.subs[sub_index].requested);
10760 transport
10761 .download_segment(
10762 &SegmentRequest {
10763 segment_id: segment_id.clone(),
10764 table,
10765 requested_scopes_json,
10766 },
10767 &mut on_progress,
10768 )
10769 .map_err(|e| SectionError::Abort(e.code, e.message))?
10770 };
10771 on_progress(bytes.len() as u64);
10773 let digest = Sha256::digest(&bytes);
10774 let expected = segment_id
10775 .strip_prefix("sha256:")
10776 .unwrap_or(segment_id.as_str());
10777 if bytes_to_hex(&digest) != expected {
10778 self.schedule_background_retry();
10780 return Err(SectionError::Abort(
10781 "sync.invalid_request".to_owned(),
10782 "segment bytes do not match the content address (§5.1)".to_owned(),
10783 ));
10784 }
10785 self.progress.update(|p| p.phase = ProgressPhase::Import);
10786 if media_type == MediaType::Sqlite {
10787 if row_cursor.is_some() || next_row_cursor.is_some() {
10790 return Err(SectionError::Abort(
10791 "sync.invalid_request".to_owned(),
10792 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
10793 .to_owned(),
10794 ));
10795 }
10796 let first = !saw_segment;
10797 saw_segment = true;
10798 let applied = self.apply_sqlite_segment(
10799 sub_index,
10800 &bytes,
10801 fresh && first,
10802 row_count,
10803 as_of_commit_seq,
10804 &scope_digest,
10805 )?;
10806 report.segment_rows_applied += applied;
10807 } else {
10808 let segment = decode_rows_segment(&bytes).map_err(|e| {
10809 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
10810 })?;
10811 let first = row_cursor.is_none();
10812 saw_segment = true;
10813 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10814 report.segment_rows_applied += applied;
10815 }
10816 }
10817 Frame::Unknown { .. } => {}
10818 Frame::Error { code, message, .. } => {
10819 return Err(SectionError::Abort(code, message))
10820 }
10821 _ => {
10822 return Err(SectionError::Abort(
10823 "sync.invalid_request".to_owned(),
10824 "unexpected frame inside a subscription section".to_owned(),
10825 ));
10826 }
10827 }
10828 }
10829 Ok(())
10830 }
10831
10832 fn apply_commit_frame(
10834 &mut self,
10835 tables: &[String],
10836 changes: &[ssp2::model::Change],
10837 ) -> Result<(), (String, String)> {
10838 #[cfg(feature = "bench-internals")]
10839 let _phase = self.benchmark_phases.start(Phase::CommitApply);
10840 let was_dirty = self.overlay_dirty.get();
10841 self.begin_observation("syncular_commit")
10842 .map_err(|message| ("storage.failed".into(), message))?;
10843 let applied = (|| {
10844 let mut batch = ChangeAccumulator::default();
10845 self.record_commit_changes(&mut batch, tables, changes);
10846 self.apply_commit_changes(tables, changes)?;
10847 self.rebuild_overlay_if_dirty();
10848 self.finish_observation("syncular_commit", batch)
10849 .map_err(|message| ("storage.failed".into(), message))
10850 })();
10851 if applied.is_err() {
10852 self.rollback_observation("syncular_commit");
10853 self.overlay_dirty.set(was_dirty);
10854 }
10855 applied
10856 }
10857
10858 fn apply_commit_changes(
10859 &mut self,
10860 tables: &[String],
10861 changes: &[ssp2::model::Change],
10862 ) -> Result<(), (String, String)> {
10863 let mirror_visible = !self.overlay_dirty.get()
10868 && self.failed_commits.is_empty()
10869 && (self.outbox.is_empty()
10870 || changes.iter().all(|change| {
10871 tables
10872 .get(change.table_index as usize)
10873 .and_then(|name| self.schema.table(name))
10874 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
10875 }));
10876 for change in changes {
10877 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
10878 (
10879 "sync.invalid_request".to_owned(),
10880 "change tableIndex out of range".to_owned(),
10881 )
10882 })?;
10883 let table = self.schema.table(table_name).ok_or_else(|| {
10884 (
10885 "sync.schema_mismatch".to_owned(),
10886 format!("change targets unknown table {table_name:?}"),
10887 )
10888 })?;
10889 match change.op {
10890 Op::Upsert => {
10891 let payload = change.row.as_ref().ok_or_else(|| {
10892 (
10893 "sync.invalid_request".to_owned(),
10894 "upsert change without row payload".to_owned(),
10895 )
10896 })?;
10897 let row = {
10899 #[cfg(feature = "bench-internals")]
10900 let _phase = self.benchmark_phases.start(Phase::RowDecode);
10901 decode_row_bytes(table, payload, &self.encryption)
10902 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
10903 };
10904 let version = change.row_version.unwrap_or(0);
10905 let table_name = table.name.clone();
10906 self.write_base_row(&table_name, &row, version)
10907 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10908 if mirror_visible {
10909 self.write_row(&visible_table(&table_name), &table_name, &row, version)
10910 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10911 }
10912 }
10913 Op::Delete => {
10914 self.delete_base_row(table_name, &change.row_id)
10915 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10916 if mirror_visible {
10917 let sql = format!(
10918 "DELETE FROM {} WHERE {}",
10919 visible_table(table_name),
10920 row_id_predicate(table)
10921 );
10922 self.conn
10923 .prepare_cached(&sql)
10924 .and_then(|mut statement| {
10925 statement.execute(rusqlite::params![change.row_id])
10926 })
10927 .map_err(|error| {
10928 ("sync.invalid_request".to_owned(), error.to_string())
10929 })?;
10930 }
10931 }
10932 }
10933 }
10934 if mirror_visible {
10935 if !self.outbox.is_empty() {
10936 let changed_rows: HashSet<(&str, &str)> = changes
10937 .iter()
10938 .map(|change| {
10939 (
10940 tables[change.table_index as usize].as_str(),
10941 change.row_id.as_str(),
10942 )
10943 })
10944 .collect();
10945 self.apply_outbox_ops(
10946 self.outbox
10947 .iter()
10948 .flat_map(|commit| &commit.ops)
10949 .filter(|op| {
10950 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
10951 }),
10952 );
10953 }
10954 self.overlay_dirty.set(false);
10955 }
10956 Ok(())
10957 }
10958
10959 fn apply_segment(
10964 &mut self,
10965 sub_index: usize,
10966 segment: &RowsSegment,
10967 first_fresh_page: bool,
10968 ) -> Result<u32, SectionError> {
10969 let (sub_table, effective) = {
10970 let sub = &self.subs[sub_index];
10971 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10972 };
10973 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10974 SectionError::Abort(
10975 "sync.schema_mismatch".to_owned(),
10976 format!("subscription table {sub_table:?} is not in the client schema"),
10977 )
10978 })?;
10979 let matches = segment.table == table.name
10984 && segment.schema_version == self.schema.version
10985 && segment.columns.len() == table.wire_columns.len()
10986 && segment
10987 .columns
10988 .iter()
10989 .zip(table.wire_columns.iter())
10990 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
10991 if !matches {
10992 return Err(SectionError::Abort(
10993 "sync.schema_mismatch".to_owned(),
10994 "segment column table does not match the generated schema (§5.2)".to_owned(),
10995 ));
10996 }
10997 let mut applied = 0u32;
10998 let empty = Vec::new();
10999 let blocks = if segment.blocks.is_empty() {
11000 std::slice::from_ref(&empty)
11001 } else {
11002 &segment.blocks
11003 };
11004 for (index, block) in blocks.iter().enumerate() {
11005 let clear = first_fresh_page && index == 0;
11006 if !clear && block.is_empty() {
11007 continue;
11008 }
11009 let was_dirty = self.overlay_dirty.get();
11010 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
11011 && self
11012 .outbox
11013 .iter()
11014 .flat_map(|commit| &commit.ops)
11015 .any(|op| op.table == table.name);
11016 let mirror_visible = !reconcile_pending && self.failed_commits.is_empty();
11017 self.begin_observation("syncular_segment_block")
11018 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11019 let outcome = (|| {
11020 self.rebuild_overlay_if_dirty();
11021 let mut changed_keys = Vec::new();
11022 let mut batch = ChangeAccumulator::default();
11023 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
11024 batch.table(&table.name);
11025 }
11026 if clear {
11027 self.purge_scope_rows(&table.name, &effective)
11028 .map_err(|()| SectionError::FailClosed)?;
11029 }
11030 for (row_index, row) in block.iter().enumerate() {
11031 let decrypted;
11032 let values = if table.has_encrypted_columns() {
11033 let mut values = row.values.clone();
11034 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
11035 .map_err(|message| {
11036 SectionError::Abort("client.decrypt_failed".into(), message)
11037 })?;
11038 decrypted = values;
11039 &decrypted
11040 } else {
11041 &row.values
11042 };
11043 self.write_base_row(&table.name, values, row.server_version)
11044 .map_err(|message| {
11045 SectionError::Abort("sync.invalid_request".into(), message)
11046 })?;
11047 if reconcile_pending {
11048 changed_keys.push(
11049 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
11050 |message| SectionError::Abort("storage.failed".into(), message),
11051 )?,
11052 );
11053 }
11054 if mirror_visible {
11055 self.write_row(
11056 &visible_table(&table.name),
11057 &table.name,
11058 values,
11059 row.server_version,
11060 )
11061 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11062 }
11063 let processed = u64::from(applied) + row_index as u64 + 1;
11064 if processed.is_multiple_of(1024) {
11065 self.progress.update(|p| p.rows_processed = processed);
11066 }
11067 }
11068 if reconcile_pending {
11069 self.reconcile_imported_rows(&table, &mut changed_keys)
11070 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11071 }
11072 if self.failed_commits.is_empty() {
11073 self.apply_outbox_ops(
11074 self.outbox
11075 .iter()
11076 .flat_map(|commit| &commit.ops)
11077 .filter(|op| op.table == table.name),
11078 );
11079 self.overlay_dirty.set(false);
11080 } else {
11081 self.rebuild_overlay();
11082 }
11083 self.finish_observation("syncular_segment_block", batch)
11084 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
11085 })();
11086 if let Err(error) = outcome {
11087 self.rollback_observation("syncular_segment_block");
11088 self.overlay_dirty.set(was_dirty);
11089 return Err(error);
11090 }
11091 applied += block.len() as u32;
11092 self.progress
11093 .update(|p| p.rows_processed = u64::from(applied));
11094 std::thread::yield_now();
11095 }
11096 self.progress
11097 .update(|p| p.rows_processed = u64::from(applied));
11098 Ok(applied)
11099 }
11100
11101 fn apply_sqlite_segment(
11109 &mut self,
11110 sub_index: usize,
11111 bytes: &[u8],
11112 first_fresh_page: bool,
11113 row_count: i64,
11114 as_of_commit_seq: i64,
11115 scope_digest: &str,
11116 ) -> Result<u32, SectionError> {
11117 let invalid = |detail: &str| {
11118 SectionError::Abort(
11119 "sync.invalid_request".to_owned(),
11120 format!("sqlite segment rejected: {detail} (§5.3)"),
11121 )
11122 };
11123 let (sub_table, effective) = {
11124 let sub = &self.subs[sub_index];
11125 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11126 };
11127 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
11128 SectionError::Abort(
11129 "sync.schema_mismatch".to_owned(),
11130 format!("subscription table {sub_table:?} is not in the client schema"),
11131 )
11132 })?;
11133
11134 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
11135 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
11136 let img = match rusqlite::Connection::open_with_flags(
11137 &path,
11138 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
11139 ) {
11140 Ok(conn) => conn,
11141 Err(_) => {
11142 let _ = std::fs::remove_file(&path);
11143 return Err(invalid("bytes do not open as a SQLite database"));
11144 }
11145 };
11146 let outcome = self.apply_sqlite_image(
11147 &img,
11148 &table,
11149 first_fresh_page,
11150 &effective,
11151 row_count,
11152 as_of_commit_seq,
11153 scope_digest,
11154 );
11155 drop(img);
11156 let _ = std::fs::remove_file(&path);
11157 outcome
11158 }
11159
11160 #[allow(clippy::too_many_arguments)]
11161 fn apply_sqlite_image(
11162 &mut self,
11163 img: &rusqlite::Connection,
11164 table: &crate::schema::TableSchema,
11165 first_fresh_page: bool,
11166 effective: &[(String, Vec<String>)],
11167 row_count: i64,
11168 as_of_commit_seq: i64,
11169 scope_digest: &str,
11170 ) -> Result<u32, SectionError> {
11171 let invalid = |detail: String| {
11172 SectionError::Abort(
11173 "sync.invalid_request".to_owned(),
11174 format!("sqlite segment rejected: {detail} (§5.3)"),
11175 )
11176 };
11177
11178 type MetaRow = (i64, String, i64, i64, String, i64, i64);
11181 let meta: MetaRow = img
11182 .query_row(
11183 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
11184 \"scopeDigest\", \"rowCount\",
11185 (SELECT count(*) FROM _syncular_segment)
11186 FROM _syncular_segment",
11187 [],
11188 |row| {
11189 Ok((
11190 row.get(0)?,
11191 row.get(1)?,
11192 row.get(2)?,
11193 row.get(3)?,
11194 row.get(4)?,
11195 row.get(5)?,
11196 row.get(6)?,
11197 ))
11198 },
11199 )
11200 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
11201 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
11202 if meta_count != 1 {
11203 return Err(invalid(format!(
11204 "_syncular_segment must contain exactly one row, found {meta_count}"
11205 )));
11206 }
11207 if format != 1 {
11208 return Err(invalid(format!("format {format}")));
11209 }
11210 if meta_table != table.name {
11211 return Err(invalid(format!("image table {meta_table:?}")));
11212 }
11213 if schema_version != i64::from(self.schema.version) {
11214 return Err(invalid(format!("schemaVersion {schema_version}")));
11215 }
11216 if pin != as_of_commit_seq {
11217 return Err(invalid(format!("asOfCommitSeq {pin}")));
11218 }
11219 if digest != scope_digest {
11220 return Err(invalid("scopeDigest mismatch".to_owned()));
11221 }
11222 if meta_rows != row_count {
11223 return Err(invalid(format!("rowCount {meta_rows}")));
11224 }
11225
11226 let mut names: Vec<String> = Vec::new();
11228 let mut primary_keys: Vec<String> = Vec::new();
11229 {
11230 let mut stmt = img
11231 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
11232 .map_err(|_| invalid("image data table missing".to_owned()))?;
11233 let mut rows = stmt
11234 .query([])
11235 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
11236 while let Some(row) = rows
11237 .next()
11238 .map_err(|_| invalid("image data table unreadable".to_owned()))?
11239 {
11240 if row
11241 .get::<_, i64>(5)
11242 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
11243 > 0
11244 {
11245 primary_keys.push(row.get(1).map_err(|error| {
11246 invalid(Self::sqlite_failure(&self.storage_failure, error))
11247 })?);
11248 }
11249 names.push(
11250 row.get::<_, String>(1)
11251 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
11252 );
11253 }
11254 }
11255 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
11256 expected.push("_syncular_version");
11257 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
11258 return Err(SectionError::Abort(
11259 "sync.schema_mismatch".to_owned(),
11260 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
11261 ));
11262 }
11263
11264 if primary_keys != vec![table.primary_key.clone()] {
11265 return Err(invalid(
11266 "image primary key does not match generated schema".into(),
11267 ));
11268 }
11269 let actual_count: i64 = img
11270 .query_row(
11271 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
11272 [],
11273 |row| row.get(0),
11274 )
11275 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
11276 if actual_count != row_count {
11277 return Err(invalid("image row count does not match descriptor".into()));
11278 }
11279 let insert = self.insert_row_sql(&base_table(&table.name), table);
11280 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
11281 let column_list = names
11282 .iter()
11283 .map(|n| quote_ident(n))
11284 .collect::<Vec<_>>()
11285 .join(", ");
11286 let mut stmt = img
11287 .prepare(&format!(
11288 "SELECT {column_list} FROM {} ORDER BY {}",
11289 quote_ident(&table.name),
11290 quote_ident(&table.primary_key)
11291 ))
11292 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
11293 let mut rows = stmt
11294 .query([])
11295 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
11296 let mut applied = 0u32;
11297 loop {
11298 let clear = applied == 0 && first_fresh_page;
11299 let was_dirty = self.overlay_dirty.get();
11300 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
11301 && self
11302 .outbox
11303 .iter()
11304 .flat_map(|commit| &commit.ops)
11305 .any(|op| op.table == table.name);
11306 let mirror_visible = !reconcile_pending && self.failed_commits.is_empty();
11307 self.begin_observation("syncular_image_chunk")
11308 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11309 let result = (|| {
11310 self.rebuild_overlay_if_dirty();
11311 let mut changed_keys = Vec::new();
11312 let mut batch = ChangeAccumulator::default();
11313 if clear {
11314 if self.scoped_rows_exist(&table.name, effective) {
11315 batch.table(&table.name);
11316 }
11317 self.purge_scope_rows(&table.name, effective)
11318 .map_err(|()| SectionError::FailClosed)?;
11319 }
11320 let mut processed = 0u32;
11321 {
11322 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
11323 invalid(Self::sqlite_failure(&self.storage_failure, error))
11324 })?;
11325 let mut mirror = if mirror_visible {
11326 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
11327 invalid(Self::sqlite_failure(&self.storage_failure, error))
11328 })?)
11329 } else {
11330 None
11331 };
11332 for _ in 0..1024 {
11333 let Some(row) = rows.next().map_err(|error| {
11334 invalid(Self::sqlite_failure(&self.storage_failure, error))
11335 })?
11336 else {
11337 break;
11338 };
11339 for (i, column) in table.columns.iter().enumerate() {
11340 let cell = row.get_ref(i).map_err(|error| {
11341 invalid(Self::sqlite_failure(&self.storage_failure, error))
11342 })?;
11343 let param = image_cell_param(column, cell).map_err(invalid)?;
11344 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
11345 invalid(Self::sqlite_failure(&self.storage_failure, error))
11346 })?;
11347 if let Some(mirror) = &mut mirror {
11348 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
11349 invalid(Self::sqlite_failure(&self.storage_failure, error))
11350 })?;
11351 }
11352 }
11353 let version_index = table.columns.len();
11354 let version: i64 = row.get(version_index).map_err(|error| {
11355 invalid(Self::sqlite_failure(&self.storage_failure, error))
11356 })?;
11357 if version < 1 {
11358 return Err(invalid("image row version must be positive".into()));
11359 }
11360 ins.raw_bind_parameter(version_index + 1, version)
11361 .map_err(|error| {
11362 invalid(Self::sqlite_failure(&self.storage_failure, error))
11363 })?;
11364 ins.raw_execute().map_err(|error| {
11365 invalid(Self::sqlite_failure(&self.storage_failure, error))
11366 })?;
11367 if let Some(mirror) = &mut mirror {
11368 mirror
11369 .raw_bind_parameter(version_index + 1, version)
11370 .map_err(|error| {
11371 invalid(Self::sqlite_failure(&self.storage_failure, error))
11372 })?;
11373 mirror.raw_execute().map_err(|error| {
11374 invalid(Self::sqlite_failure(&self.storage_failure, error))
11375 })?;
11376 }
11377 if reconcile_pending {
11378 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
11379 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
11380 )?);
11381 }
11382 processed += 1;
11383 }
11384 }
11385 if processed > 0 {
11386 batch.table(&table.name);
11387 self.overlay_dirty.set(true);
11388 }
11389 if reconcile_pending {
11390 self.reconcile_imported_rows(table, &mut changed_keys)
11391 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11392 }
11393 if self.failed_commits.is_empty() {
11394 self.apply_outbox_ops(
11395 self.outbox
11396 .iter()
11397 .flat_map(|commit| &commit.ops)
11398 .filter(|op| op.table == table.name),
11399 );
11400 self.overlay_dirty.set(false);
11401 } else {
11402 self.rebuild_overlay();
11403 }
11404 self.finish_observation("syncular_image_chunk", batch)
11405 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11406 Ok(processed)
11407 })();
11408 let processed = match result {
11409 Ok(processed) => processed,
11410 Err(error) => {
11411 self.rollback_observation("syncular_image_chunk");
11412 self.overlay_dirty.set(was_dirty);
11413 return Err(error);
11414 }
11415 };
11416 applied += processed;
11417 self.progress
11418 .update(|p| p.rows_processed = u64::from(applied));
11419 std::thread::yield_now();
11420 if i64::from(applied) == actual_count {
11421 break;
11422 }
11423 }
11424 Ok(applied)
11425 }
11426
11427 fn reconcile_imported_rows(
11431 &self,
11432 table: &TableSchema,
11433 keys: &mut Vec<SqlValue>,
11434 ) -> Result<(), String> {
11435 let visible = visible_table(&table.name);
11436 let base = base_table(&table.name);
11437 let pk = quote_ident(&table.primary_key);
11438 let predicate = row_id_predicate(table);
11439 let mut lookup = self.conn.prepare_cached(&format!(
11440 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
11441 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11442 let mut deleted = HashSet::new();
11443 for op in self
11444 .outbox
11445 .iter()
11446 .flat_map(|commit| &commit.ops)
11447 .filter(|op| op.table == table.name)
11448 {
11449 if op.upsert {
11450 if let Some(values) = &op.values {
11451 let key = json_to_column_value(
11452 &table.columns[table.pk_index],
11453 values.get(&table.primary_key),
11454 )?;
11455 keys.push(owned_sql_value(RowParam::Cell(&key))?);
11456 }
11457 } else if deleted.insert(&op.row_id) {
11458 keys.extend(
11459 lookup
11460 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
11461 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
11462 .collect::<Result<Vec<_>, _>>()
11463 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
11464 );
11465 }
11466 }
11467 for chunk in keys.chunks(400) {
11470 let holes = vec!["?"; chunk.len()].join(",");
11471 self.conn
11472 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
11473 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
11474 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11475 }
11476 let updates = table
11477 .columns
11478 .iter()
11479 .map(|column| column.name.as_str())
11480 .chain(std::iter::once("_syncular_version"))
11481 .filter(|name| *name != table.primary_key)
11482 .map(|name| {
11483 let name = quote_ident(name);
11484 format!("{name}=excluded.{name}")
11485 })
11486 .collect::<Vec<_>>()
11487 .join(",");
11488 for chunk in keys.chunks(400) {
11489 let holes = vec!["?"; chunk.len()].join(",");
11490 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
11491 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
11492 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11493 }
11494 Ok(())
11495 }
11496
11497 fn compile_local_data_purge(
11500 &self,
11501 input: &LocalDataPurgeInput,
11502 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
11503 let invalid = |message: String| format!("sync.invalid_request: {message}");
11504 if input.purge_id.is_empty()
11505 || input.purge_id.len() > 128
11506 || !is_local_operation_code_like(&input.purge_id)
11507 {
11508 return Err(invalid(
11509 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
11510 ));
11511 }
11512 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
11513 return Err(invalid(format!(
11514 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
11515 )));
11516 }
11517
11518 let mut deduplicated: BTreeMap<
11519 String,
11520 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
11521 > = BTreeMap::new();
11522 for target in &input.targets {
11523 let table = self.schema.table(&target.table).ok_or_else(|| {
11524 invalid(format!(
11525 "local purge names unknown table {:?}",
11526 target.table
11527 ))
11528 })?;
11529 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
11530 return Err(invalid(format!(
11531 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
11532 target.table
11533 )));
11534 }
11535 let mut selectors = Vec::with_capacity(target.selectors.len());
11536 let mut canonical_selectors = BTreeMap::new();
11537 for (column_name, raw_values) in &target.selectors {
11538 let Some((column_index, column)) = table
11539 .columns
11540 .iter()
11541 .enumerate()
11542 .find(|(_, column)| column.name == *column_name)
11543 else {
11544 return Err(invalid(format!(
11545 "local purge target {:?} names unknown column {:?}",
11546 target.table, column_name
11547 )));
11548 };
11549 let encrypted = table
11550 .encrypted_columns
11551 .iter()
11552 .any(|candidate| candidate.index == column_index);
11553 if column.ty != ColumnType::String || encrypted {
11554 return Err(invalid(format!(
11555 "local purge selector {:?}.{:?} must be a plaintext string column",
11556 target.table, column_name
11557 )));
11558 }
11559 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
11560 return Err(invalid(format!(
11561 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
11562 target.table, column_name
11563 )));
11564 }
11565 let mut values = raw_values.clone();
11566 values.sort();
11567 values.dedup();
11568 if values.iter().any(|value| {
11569 value.is_empty()
11570 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
11571 || !is_local_operation_code_like(value)
11572 }) {
11573 return Err(invalid(format!(
11574 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
11575 )));
11576 }
11577 selectors.push((column_name.clone(), values.clone()));
11578 canonical_selectors.insert(column_name.clone(), values);
11579 }
11580 let canonical = LocalDataPurgeTarget {
11581 table: target.table.clone(),
11582 selectors: canonical_selectors,
11583 };
11584 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
11585 deduplicated.insert(
11586 key,
11587 (
11588 CompiledLocalDataPurgeTarget {
11589 table: target.table.clone(),
11590 selectors,
11591 },
11592 canonical,
11593 ),
11594 );
11595 }
11596 let targets = deduplicated
11597 .values()
11598 .map(|(compiled, _)| compiled.clone())
11599 .collect::<Vec<_>>();
11600 let canonical = deduplicated
11601 .values()
11602 .map(|(_, target)| target.clone())
11603 .collect::<Vec<_>>();
11604 let canonical_plan =
11605 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
11606 Ok((targets, canonical_plan))
11607 }
11608
11609 fn local_purge_base_row_ids(
11610 &self,
11611 targets: &[CompiledLocalDataPurgeTarget],
11612 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
11613 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11614 for target in targets {
11615 let table = self
11616 .schema
11617 .table(&target.table)
11618 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
11619 let mut clauses = Vec::with_capacity(target.selectors.len());
11620 let mut params = Vec::new();
11621 for (column, values) in &target.selectors {
11622 clauses.push(format!(
11623 "{} IN ({})",
11624 quote_ident(column),
11625 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
11626 ));
11627 params.extend(values.iter().cloned().map(SqlValue::Text));
11628 }
11629 let sql = format!(
11630 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
11631 quote_ident(&table.primary_key),
11632 base_table(&target.table),
11633 clauses.join(" AND ")
11634 );
11635 let mut statement = self
11636 .conn
11637 .prepare(&sql)
11638 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11639 let rows = statement
11640 .query_map(rusqlite::params_from_iter(params), |row| {
11641 row.get::<_, String>(0)
11642 })
11643 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11644 let ids = by_table.entry(target.table.clone()).or_default();
11645 for row in rows {
11646 ids.insert(
11647 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
11648 );
11649 }
11650 }
11651 Ok(by_table)
11652 }
11653
11654 fn local_purge_values_match(
11655 target: &CompiledLocalDataPurgeTarget,
11656 values: &Map<String, Value>,
11657 ) -> bool {
11658 target.selectors.iter().all(|(column, allowed)| {
11659 values
11660 .get(column)
11661 .and_then(Value::as_str)
11662 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
11663 })
11664 }
11665
11666 pub fn purge_local_data(
11670 &mut self,
11671 input: &LocalDataPurgeInput,
11672 ) -> Result<LocalDataPurgeResult, String> {
11673 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
11674 let meta_key = format!("localPurge:{}", input.purge_id);
11675 let applied_plan = self
11676 .conn
11677 .query_row(
11678 "SELECT value FROM _syncular_meta WHERE key = ?1",
11679 rusqlite::params![meta_key],
11680 |row| row.get::<_, String>(0),
11681 )
11682 .optional()
11683 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11684 if let Some(applied_plan) = applied_plan {
11685 if applied_plan != canonical_plan {
11686 return Err(format!(
11687 "sync.invalid_request: local purge id {:?} was already used with a different plan",
11688 input.purge_id
11689 ));
11690 }
11691 return Ok(LocalDataPurgeResult {
11692 already_applied: true,
11693 purged_rows: 0,
11694 dropped_commits: 0,
11695 });
11696 }
11697
11698 let _ = self.drop_previous_version();
11702
11703 let prior_outbox = self.outbox.clone();
11704 let prior_failed = self.failed_commits.clone();
11705 let prior_rejection_count = self.rejections.len();
11706 let prior_overlay_dirty = self.overlay_dirty.get();
11707 self.begin_observation("syncular_local_purge")?;
11708 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
11709 let row_ids = self.local_purge_base_row_ids(&targets)?;
11710 let retained_dropped = self.drop_failed_commits(|table, values| {
11711 targets.iter().any(|target| {
11712 target.table == table && Self::local_purge_values_match(target, values)
11713 })
11714 })?;
11715 let doomed = self
11716 .outbox
11717 .iter()
11718 .filter(|commit| {
11719 commit.ops.iter().any(|operation| {
11720 let matching_targets = targets
11721 .iter()
11722 .filter(|target| target.table == operation.table)
11723 .collect::<Vec<_>>();
11724 if matching_targets.is_empty() {
11725 return false;
11726 }
11727 if row_ids
11728 .get(&operation.table)
11729 .is_some_and(|ids| ids.contains(&operation.row_id))
11730 {
11731 return true;
11732 }
11733 operation.values.as_ref().is_some_and(|values| {
11734 matching_targets
11735 .iter()
11736 .any(|target| Self::local_purge_values_match(target, values))
11737 })
11738 })
11739 })
11740 .cloned()
11741 .collect::<Vec<_>>();
11742 let doomed_ids = doomed
11743 .iter()
11744 .map(|commit| commit.client_commit_id.clone())
11745 .collect::<BTreeSet<_>>();
11746 let mut batch = ChangeAccumulator::default();
11747 for commit in &retained_dropped {
11748 batch.outcomes = true;
11749 for operation in &commit.ops {
11750 batch.table(&operation.table);
11751 }
11752 }
11753 let mut rejections = Vec::new();
11754 for commit in &doomed {
11755 for operation in &commit.ops {
11756 batch.table(&operation.table);
11757 }
11758 let results = commit
11759 .ops
11760 .iter()
11761 .enumerate()
11762 .map(|(op_index, operation)| {
11763 let rejection = RejectionRecord {
11764 client_commit_id: commit.client_commit_id.clone(),
11765 op_index: op_index as i32,
11766 code: "client.local_data_purged".to_owned(),
11767 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
11768 retryable: false,
11769 details: None,
11770 operation: Some(CommitOperation::from(operation)),
11771 };
11772 rejections.push(rejection.clone());
11773 CommitOperationOutcome::Error { rejection }
11774 })
11775 .collect::<Vec<_>>();
11776 self.persist_commit_outcome(
11777 &commit.client_commit_id,
11778 CommitOutcomeStatus::Rejected,
11779 &results,
11780 Some(&commit.ops),
11781 )?;
11782 self.delete_outbox_persisted(&commit.client_commit_id)?;
11783 }
11784 if !doomed.is_empty() {
11785 self.outbox
11786 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
11787 self.rejections.extend(rejections);
11788 self.prune_commit_outcomes()?;
11789 self.overlay_dirty.set(true);
11790 batch.status = true;
11791 batch.rejections = true;
11792 batch.outcomes = true;
11793 }
11794
11795 let mut purged_rows = 0usize;
11796 for (table, ids) in &row_ids {
11797 if ids.is_empty() {
11798 continue;
11799 }
11800 batch.table(table);
11801 for row_id in ids {
11802 self.delete_base_row(table, row_id)?;
11803 purged_rows += 1;
11804 }
11805 }
11806 self.rebuild_overlay_if_dirty();
11807 self.delete_unreferenced_cached_blobs();
11808 self.conn
11809 .execute(
11810 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11811 rusqlite::params![meta_key, canonical_plan],
11812 )
11813 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11814 Ok((
11815 batch,
11816 LocalDataPurgeResult {
11817 already_applied: false,
11818 purged_rows,
11819 dropped_commits: doomed.len(),
11820 },
11821 ))
11822 })();
11823 let (batch, result) = match applied {
11824 Ok(value) => value,
11825 Err(error) => {
11826 self.rollback_observation("syncular_local_purge");
11827 self.outbox = prior_outbox;
11828 self.failed_commits = prior_failed;
11829 self.rejections.truncate(prior_rejection_count);
11830 self.overlay_dirty.set(prior_overlay_dirty);
11831 return Err(error);
11832 }
11833 };
11834 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
11835 self.rollback_observation("syncular_local_purge");
11836 self.outbox = prior_outbox;
11837 self.failed_commits = prior_failed;
11838 self.rejections.truncate(prior_rejection_count);
11839 self.overlay_dirty.set(prior_overlay_dirty);
11840 return Err(error);
11841 }
11842 Ok(result)
11843 }
11844
11845 pub fn rebootstrap_local_data(
11851 &mut self,
11852 input: &LocalDataRebootstrapInput,
11853 ) -> Result<LocalDataRebootstrapResult, String> {
11854 if input.rebootstrap_id.is_empty()
11855 || input.rebootstrap_id.len() > 128
11856 || !is_local_operation_code_like(&input.rebootstrap_id)
11857 {
11858 return Err(
11859 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
11860 .to_owned(),
11861 );
11862 }
11863 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
11864 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
11865 let (retained_commits, reset_subscriptions) =
11866 decode_local_rebootstrap_receipt(&persisted)?;
11867 return Ok(LocalDataRebootstrapResult {
11868 already_applied: true,
11869 retained_commits,
11870 reset_subscriptions,
11871 });
11872 }
11873 if self.stopped || self.schema_floor.is_some() {
11874 return Err(
11875 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
11876 .to_owned(),
11877 );
11878 }
11879
11880 let retained_commits = self.outbox.len();
11881 let reset_subscriptions = self.subs.len();
11882 let prior_subs = self.subs.clone();
11883 let prior_upgrading = self.upgrading;
11884 let prior_stopped = self.stopped;
11885 let prior_schema_floor = self.schema_floor.clone();
11886 let prior_overlay_dirty = self.overlay_dirty.get();
11887 let prior_sync_needed = self.sync_needed;
11888 let prior_sync_intents = self.sync_intent_queue.clone();
11889 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
11890
11891 self.begin_observation("syncular_local_rebootstrap")?;
11892 let mut batch = ChangeAccumulator::default();
11893 let applied = (|| -> Result<(), String> {
11894 self.run_schema_reset_observed(&mut batch, false, false)?;
11895 self.conn
11896 .execute(
11897 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11898 rusqlite::params![meta_key, receipt],
11899 )
11900 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11901 self.sync_needed = true;
11902 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11903 batch.status = true;
11904 Ok(())
11905 })();
11906 if let Err(error) = applied {
11907 self.rollback_observation("syncular_local_rebootstrap");
11908 self.subs = prior_subs;
11909 self.upgrading = prior_upgrading;
11910 self.stopped = prior_stopped;
11911 self.schema_floor = prior_schema_floor;
11912 self.overlay_dirty.set(prior_overlay_dirty);
11913 self.sync_needed = prior_sync_needed;
11914 self.sync_intent_queue = prior_sync_intents;
11915 return Err(error);
11916 }
11917 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
11918 self.rollback_observation("syncular_local_rebootstrap");
11919 self.subs = prior_subs;
11920 self.upgrading = prior_upgrading;
11921 self.stopped = prior_stopped;
11922 self.schema_floor = prior_schema_floor;
11923 self.overlay_dirty.set(prior_overlay_dirty);
11924 self.sync_needed = prior_sync_needed;
11925 self.sync_intent_queue = prior_sync_intents;
11926 return Err(error);
11927 }
11928
11929 Ok(LocalDataRebootstrapResult {
11930 already_applied: false,
11931 retained_commits,
11932 reset_subscriptions,
11933 })
11934 }
11935
11936 fn purge_scope_rows(
11941 &mut self,
11942 table_name: &str,
11943 effective: &[(String, Vec<String>)],
11944 ) -> Result<(), ()> {
11945 if effective.is_empty() {
11946 return Ok(());
11947 }
11948 let table = self.schema.table(table_name).ok_or(())?.clone();
11949 let mut clauses = Vec::new();
11950 let mut params: Vec<SqlValue> = Vec::new();
11951 for (variable, values) in effective {
11952 let column = table.scope_column(variable).ok_or(())?;
11953 let placeholders: Vec<String> = values
11954 .iter()
11955 .map(|v| {
11956 params.push(SqlValue::Text(v.clone()));
11957 "?".to_owned()
11958 })
11959 .collect();
11960 clauses.push(format!(
11961 "{} IN ({})",
11962 quote_ident(column),
11963 placeholders.join(", ")
11964 ));
11965 }
11966 let sql = format!(
11967 "DELETE FROM {} WHERE {}",
11968 base_table(table_name),
11969 clauses.join(" AND ")
11970 );
11971 self.overlay_dirty.set(true);
11972 self.conn
11973 .execute(&sql, rusqlite::params_from_iter(¶ms))
11974 .map_err(|_| ())?;
11975 self.conn
11976 .execute(
11977 &format!(
11978 "DELETE FROM {} WHERE {}",
11979 visible_table(table_name),
11980 clauses.join(" AND ")
11981 ),
11982 rusqlite::params_from_iter(¶ms),
11983 )
11984 .map_err(|_| ())?;
11985 Ok(())
11986 }
11987
11988 fn drop_doomed_outbox(
11991 &mut self,
11992 table_name: &str,
11993 effective: &[(String, Vec<String>)],
11994 ) -> Result<bool, String> {
11995 if effective.is_empty() {
11996 return Ok(false);
11997 }
11998 let Some(table) = self.schema.table(table_name).cloned() else {
11999 return Ok(false);
12000 };
12001 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
12002 for (variable, values) in effective {
12003 match table.scope_column(variable) {
12004 Some(column) => mappings.push((column, values)),
12005 None => return Ok(false), }
12007 }
12008 let doomed: Vec<OutboxCommit> = self
12009 .outbox
12010 .iter()
12011 .filter(|commit| {
12012 commit.ops.iter().any(|op| {
12013 op.upsert
12014 && op.table == table_name
12015 && op.values.as_ref().is_some_and(|values| {
12016 mappings
12017 .iter()
12018 .all(|(column, allowed)| match values.get(*column) {
12019 Some(Value::String(s)) => allowed.contains(s),
12020 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
12021 _ => false,
12022 })
12023 })
12024 })
12025 })
12026 .cloned()
12027 .collect();
12028 if doomed.is_empty() {
12029 return Ok(false);
12030 }
12031 let mut rejections = Vec::new();
12032 for commit in &doomed {
12033 let results = commit
12034 .ops
12035 .iter()
12036 .enumerate()
12037 .map(|(op_index, operation)| {
12038 let rejection = RejectionRecord {
12039 client_commit_id: commit.client_commit_id.clone(),
12040 op_index: op_index as i32,
12041 code: "sync.scope_revoked".to_owned(),
12042 message: "the commit was dropped because its effective scope was revoked"
12043 .to_owned(),
12044 retryable: false,
12045 details: None,
12046 operation: Some(CommitOperation::from(operation)),
12047 };
12048 rejections.push(rejection.clone());
12049 CommitOperationOutcome::Error { rejection }
12050 })
12051 .collect::<Vec<_>>();
12052 self.persist_commit_outcome(
12053 &commit.client_commit_id,
12054 CommitOutcomeStatus::Rejected,
12055 &results,
12056 Some(&commit.ops),
12057 )?;
12058 self.delete_outbox_persisted(&commit.client_commit_id)?;
12059 }
12060 self.prune_commit_outcomes()?;
12061 let doomed_ids = doomed
12062 .iter()
12063 .map(|commit| commit.client_commit_id.as_str())
12064 .collect::<BTreeSet<_>>();
12065 self.outbox
12066 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
12067 self.rejections.extend(rejections);
12068 self.overlay_dirty.set(true);
12069 Ok(true)
12070 }
12071
12072 pub fn upload_blob(
12079 &mut self,
12080 bytes: &[u8],
12081 media_type: Option<String>,
12082 name: Option<String>,
12083 ) -> Result<Value, String> {
12084 let blob_id = blob_id_for(bytes);
12085 let now = self.clock_now_ms();
12086 let tx = self
12087 .conn
12088 .transaction()
12089 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12090 tx.execute(
12091 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
12092 ON CONFLICT(blob_id) DO NOTHING",
12093 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
12094 )
12095 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12096 tx.execute(
12097 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
12098 rusqlite::params![blob_id, media_type, now],
12099 )
12100 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12101 tx.commit()
12102 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12103 self.enforce_blob_cache_cap();
12104 let mut obj = Map::new();
12105 obj.insert("blobId".to_owned(), Value::from(blob_id));
12106 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
12107 if let Some(mt) = media_type {
12108 obj.insert("mediaType".to_owned(), Value::from(mt));
12109 }
12110 if let Some(n) = name {
12111 obj.insert("name".to_owned(), Value::from(n));
12112 }
12113 Ok(Value::Object(obj))
12114 }
12115
12116 pub fn fetch_blob_bytes(
12120 &mut self,
12121 transport: &mut dyn Transport,
12122 blob_id_or_ref: &str,
12123 ) -> Result<FetchedBlob, (String, String)> {
12124 let simple = |m: String| ("client.failed".to_owned(), m);
12125 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
12126 blob_id_or_ref.to_owned()
12127 } else {
12128 let value: Value = serde_json::from_str(blob_id_or_ref)
12129 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
12130 value
12131 .get("blobId")
12132 .and_then(Value::as_str)
12133 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
12134 .to_owned()
12135 };
12136 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
12137 return Ok(cached);
12138 }
12139 #[cfg(feature = "bench-internals")]
12145 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
12146 let bytes = match transport
12147 .blob_download(&blob_id)
12148 .map_err(|e| (e.code, e.message))?
12149 {
12150 BlobDownload::Bytes(bytes) => bytes,
12151 BlobDownload::Url {
12152 url,
12153 url_expires_at_ms,
12154 } => {
12155 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
12157 return Err((
12158 "sync.segment_expired".to_owned(),
12159 format!(
12160 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
12161 ),
12162 ));
12163 }
12164 transport
12165 .fetch_blob_url(&url)
12166 .map_err(|e| (e.code, e.message))?
12167 }
12168 };
12169 #[cfg(feature = "bench-internals")]
12170 drop(download_phase);
12171 #[cfg(feature = "bench-internals")]
12172 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
12173 if blob_id_for(&bytes) != blob_id {
12175 return Err(simple(format!(
12176 "blob content address mismatch for {blob_id}"
12177 )));
12178 }
12179 #[cfg(feature = "bench-internals")]
12180 drop(validate_phase);
12181 #[cfg(feature = "bench-internals")]
12182 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
12183 let now = self.clock_now_ms();
12184 self.conn
12185 .execute(
12186 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
12187 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
12188 )
12189 .map_err(|e| simple(e.to_string()))?;
12190 self.conn
12191 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
12192 .map_err(|e| simple(e.to_string()))?;
12193 self.enforce_blob_cache_cap();
12194 #[cfg(feature = "bench-internals")]
12195 drop(insert_phase);
12196 self.get_cached_blob_bytes(&blob_id)
12197 .map_err(simple)?
12198 .ok_or_else(|| simple("blob cache write failed".to_owned()))
12199 }
12200
12201 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
12202 #[cfg(feature = "bench-internals")]
12203 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
12204 let mut stmt = self
12205 .conn
12206 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
12207 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12208 let mut rows = stmt
12209 .query(rusqlite::params![blob_id])
12210 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12211 if let Some(row) = rows
12212 .next()
12213 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
12214 {
12215 let bytes: Vec<u8> = row
12216 .get(0)
12217 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12218 let byte_length: i64 = row
12219 .get(1)
12220 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12221 let media_type: Option<String> = row
12222 .get(2)
12223 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12224 return Ok(Some(FetchedBlob {
12225 blob_id: blob_id.to_owned(),
12226 byte_length,
12227 bytes,
12228 media_type,
12229 }));
12230 }
12231 Ok(None)
12232 }
12233
12234 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
12236 let pending: Vec<(String, Option<String>)> = {
12237 let mut stmt = self
12238 .conn
12239 .prepare(
12240 "SELECT blob_id, media_type FROM (
12241 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
12242 UNION ALL
12243 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
12244 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
12245 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
12246 ) ORDER BY created_at_ms, blob_id",
12247 )
12248 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12249 let rows = stmt
12250 .query_map([], |row| {
12251 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
12252 })
12253 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12254 rows.collect::<Result<_, _>>()
12255 .map_err(|error| match error {
12256 rusqlite::Error::InvalidColumnType(..)
12257 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
12258 "sync.local_corrupt",
12259 "Pending blob upload metadata is invalid",
12260 ),
12261 _ => TransportError::new("client.failed", error.to_string()),
12262 })?
12263 };
12264 for (blob_id, media_type) in pending {
12265 let stored: Option<(SqlValue, SqlValue)> = self
12266 .conn
12267 .query_row(
12268 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
12269 rusqlite::params![blob_id],
12270 |row| Ok((row.get(0)?, row.get(1)?)),
12271 )
12272 .optional()
12273 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12274 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
12275 return Err(TransportError::new(
12276 "sync.local_corrupt",
12277 "Pending blob upload body is missing or corrupt",
12278 ));
12279 };
12280 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
12281 return Err(TransportError::new(
12282 "sync.local_corrupt",
12283 "Pending blob upload body is missing or corrupt",
12284 ));
12285 }
12286 self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
12287 self.conn
12288 .execute(
12289 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
12290 rusqlite::params![blob_id],
12291 )
12292 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12293 }
12294 Ok(())
12295 }
12296
12297 fn upload_one(
12304 &self,
12305 transport: &mut dyn Transport,
12306 blob_id: &str,
12307 bytes: &[u8],
12308 media_type: Option<&str>,
12309 ) -> Result<(), TransportError> {
12310 match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
12311 BlobUploadGrant::Present => return Ok(()), BlobUploadGrant::Url {
12313 url,
12314 url_expires_at_ms,
12315 } => {
12316 let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
12317 if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
12318 return Ok(());
12319 }
12320 }
12322 BlobUploadGrant::None => {
12323 }
12325 }
12326 transport.blob_upload(blob_id, bytes, media_type)
12327 }
12328
12329 fn visible_blob_ids_sql(&self) -> String {
12330 let mut selects = Vec::new();
12331 for table in &self.schema.tables {
12332 for column in table
12333 .columns
12334 .iter()
12335 .filter(|column| column.ty == ColumnType::BlobRef)
12336 {
12337 let identifier = quote_ident(&column.name);
12338 selects.push(format!(
12339 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
12340 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
12341 AND json_type({identifier}, '$.blobId') = 'text'",
12342 quote_ident(&table.name),
12343 ));
12344 }
12345 }
12346 if selects.is_empty() {
12347 "SELECT NULL AS blob_id WHERE 0".to_owned()
12348 } else {
12349 selects.join(" UNION ")
12350 }
12351 }
12352
12353 fn enforce_blob_cache_cap(&self) {
12356 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
12357 return;
12358 };
12359 let mut total: i64 = self
12360 .conn
12361 .query_row(
12362 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
12363 [],
12364 |row| row.get(0),
12365 )
12366 .unwrap_or(0);
12367 if total <= max_bytes {
12368 return;
12369 }
12370 let query = format!(
12371 "SELECT blob_id, byte_length FROM _syncular_blobs
12372 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
12373 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
12374 AND blob_id NOT IN ({})
12375 ORDER BY created_at_ms ASC, blob_id ASC",
12376 self.visible_blob_ids_sql(),
12377 );
12378 let candidates: Vec<(String, i64)> = {
12379 let Ok(mut stmt) = self.conn.prepare(&query) else {
12380 return;
12381 };
12382 let Ok(rows) = stmt.query_map([], |row| {
12383 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
12384 }) else {
12385 return;
12386 };
12387 rows.filter_map(Result::ok).collect()
12388 };
12389 for (blob_id, byte_length) in candidates {
12390 if total <= max_bytes {
12391 break;
12392 }
12393 let _ = self.conn.execute(
12394 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
12395 rusqlite::params![blob_id],
12396 );
12397 total -= byte_length;
12398 }
12399 }
12400
12401 fn delete_unreferenced_cached_blobs(&mut self) {
12402 #[cfg(feature = "bench-internals")]
12403 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
12404 if !self.schema_has_blobs() {
12405 return;
12406 }
12407 self.rebuild_overlay_if_dirty();
12408 let _ = self.conn.execute(
12409 &format!(
12410 "DELETE FROM _syncular_blobs
12411 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
12412 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
12413 AND blob_id NOT IN ({})",
12414 self.visible_blob_ids_sql(),
12415 ),
12416 [],
12417 );
12418 }
12419
12420 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
12425 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
12426 return sql.clone();
12427 }
12428 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
12429 columns.push(quote_ident("_syncular_version"));
12430 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
12431 let primary_key = quote_ident(&table.primary_key);
12432 let updates = columns
12433 .iter()
12434 .filter(|column| **column != primary_key)
12435 .map(|column| format!("{column}=excluded.{column}"))
12436 .collect::<Vec<_>>()
12437 .join(", ");
12438 let sql = format!(
12439 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
12440 columns.join(", "),
12441 placeholders.join(", ")
12442 );
12443 self.insert_sql
12444 .borrow_mut()
12445 .insert(full_table.to_owned(), sql.clone());
12446 sql
12447 }
12448
12449 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
12450 self.overlay_dirty.set(true);
12451 self.write_row(&base_table(table_name), table_name, row, version)
12452 }
12453
12454 fn write_row(
12455 &self,
12456 full_table: &str,
12457 table_name: &str,
12458 row: &Row,
12459 version: i64,
12460 ) -> Result<(), String> {
12461 #[cfg(feature = "bench-internals")]
12462 let _phase = self.benchmark_phases.start(Phase::RowWrite);
12463 let table = self
12464 .schema
12465 .table(table_name)
12466 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
12467 let sql = self.insert_row_sql(full_table, table);
12468 let mut stmt = self
12469 .conn
12470 .prepare_cached(&sql)
12471 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12472 let params = row
12473 .iter()
12474 .map(RowParam::Cell)
12475 .chain(std::iter::once(RowParam::Version(version)));
12476 stmt.execute(rusqlite::params_from_iter(params))
12477 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12478 Ok(())
12479 }
12480
12481 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
12482 let table = self
12483 .schema
12484 .table(table_name)
12485 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
12486 self.overlay_dirty.set(true);
12487 let sql = format!(
12488 "DELETE FROM {} WHERE {}",
12489 base_table(table_name),
12490 row_id_predicate(table)
12491 );
12492 let mut stmt = self
12493 .conn
12494 .prepare_cached(&sql)
12495 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12496 stmt.execute(rusqlite::params![row_id])
12497 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12498 Ok(())
12499 }
12500
12501 fn rebuild_overlay_if_dirty(&mut self) {
12504 if self.overlay_dirty.get() {
12505 self.rebuild_overlay();
12506 }
12507 }
12508
12509 fn rebuild_overlay(&mut self) {
12513 #[cfg(feature = "bench-internals")]
12514 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
12515 #[cfg(test)]
12516 self.overlay_rebuild_count
12517 .set(self.overlay_rebuild_count.get() + 1);
12518 self.exec("SAVEPOINT syncular_overlay");
12519 for table in self.schema.tables.clone() {
12520 for index in &table.fts_indexes {
12521 let _ = self.drop_fts_triggers(index);
12522 }
12523 let visible = visible_table(&table.name);
12524 let base = base_table(&table.name);
12525 self.exec(&format!("DELETE FROM {visible}"));
12526 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
12527 }
12528 for (commit, initial) in &self.failed_commits {
12529 for operation in &commit.ops {
12530 if !operation.upsert {
12531 continue;
12532 }
12533 if let Some(table) = self.schema.table(&operation.table) {
12534 if self.visible_row(table, &operation.row_id).is_none()
12535 && operation.values.as_ref().is_some_and(|values| {
12536 table
12537 .columns
12538 .iter()
12539 .all(|column| values.contains_key(&column.name))
12540 })
12541 {
12542 let key = serde_json::to_string(&(
12543 operation.table.clone(),
12544 operation.row_id.clone(),
12545 ))
12546 .expect("row key serialization");
12547 if let Some(Value::Object(values)) = initial.get(&key) {
12548 self.apply_outbox_ops(&[OutboxOp {
12549 values: Some(values.clone()),
12550 ..operation.clone()
12551 }]);
12552 }
12553 }
12554 }
12555 }
12556 self.apply_outbox_ops(&commit.ops);
12557 }
12558 for commit in &self.outbox {
12559 self.apply_outbox_ops(&commit.ops);
12560 }
12561 for table in self.schema.tables.clone() {
12562 for index in &table.fts_indexes {
12563 let _ = self.rebuild_fts_projection(&table, index);
12564 let _ = self.create_fts_triggers(&table, index);
12565 }
12566 }
12567 self.exec("RELEASE syncular_overlay");
12568 self.overlay_dirty.set(false);
12569 }
12570
12571 fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
12576 #[cfg(feature = "bench-internals")]
12577 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
12578 for op in ops {
12579 #[cfg(feature = "bench-internals")]
12580 if let Some(phase) = &mut phase {
12581 phase.unit();
12582 }
12583 let _ = self.apply_outbox_op(op);
12584 }
12585 }
12586
12587 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
12588 let Some(table) = self.schema.table(&op.table) else {
12589 return Ok(());
12590 };
12591 let visible = visible_table(&table.name);
12592 if !op.upsert {
12593 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
12594 self.conn
12595 .prepare_cached(&sql)
12596 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
12597 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12598 return Ok(());
12599 }
12600 let Some(values) = op.values.as_ref() else {
12601 return Ok(());
12602 };
12603 let full = table
12608 .columns
12609 .iter()
12610 .all(|column| values.contains_key(&column.name));
12611 let local = self.visible_row(table, &op.row_id);
12612 if local.is_none() && !full {
12613 return Ok(());
12614 }
12615 let mut row: Row = Vec::with_capacity(table.columns.len());
12616 for (index, column) in table.columns.iter().enumerate() {
12617 let incoming = match values.get(&column.name) {
12618 Some(raw) => json_to_column_value(column, Some(raw)),
12619 None => Ok(local
12620 .as_ref()
12621 .and_then(|(values, _)| values.get(index).cloned())
12622 .flatten()),
12623 };
12624 match incoming {
12625 Ok(value) => row.push(value),
12626 Err(_) => return Ok(()),
12627 }
12628 }
12629 let version = local.as_ref().map_or(-1, |(_, version)| *version);
12630 self.write_row(&visible, &table.name, &row, version)
12631 .map_err(|error| {
12632 if error.starts_with("UNIQUE constraint failed:") {
12633 "sync.constraint_violation: local write violates a unique constraint".to_owned()
12634 } else {
12635 error
12636 }
12637 })
12638 }
12639
12640 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
12643 self.stored_row(table, row_id, false)
12644 }
12645
12646 fn stored_row(&self, table: &TableSchema, row_id: &str, base: bool) -> Option<(Row, i64)> {
12647 let sql = format!(
12648 "SELECT * FROM {} WHERE {}",
12649 if base {
12650 base_table(&table.name)
12651 } else {
12652 visible_table(&table.name)
12653 },
12654 row_id_predicate(table)
12655 );
12656 let mut stmt = self.conn.prepare_cached(&sql).ok()?;
12657 let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
12658 let row = rows.next().ok()??;
12659 let mut values = Vec::with_capacity(table.columns.len());
12660 for (index, column) in table.columns.iter().enumerate() {
12661 let raw = row.get_ref(index).ok()?;
12662 let json = sql_ref_to_json(column, raw);
12663 values.push(json_to_column_value(column, Some(&json)).ok()?);
12664 }
12665 let version: i64 = row.get(table.columns.len()).ok()?;
12666 Some((values, version))
12667 }
12668
12669 fn exec(&self, sql: &str) {
12670 let _ = self.conn.execute_batch(sql);
12671 }
12672
12673 #[must_use]
12677 pub fn realtime_policy(&self) -> RealtimePolicy {
12678 self.realtime_policy
12679 }
12680
12681 #[must_use]
12683 pub fn realtime_state(&self) -> RealtimeState {
12684 self.realtime_state
12685 }
12686
12687 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
12690 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
12691 return Err(
12692 "sync.invalid_request: disconnect realtime before changing the realtime policy"
12693 .to_owned(),
12694 );
12695 }
12696 self.realtime_policy = policy;
12697 match policy {
12698 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
12699 RealtimePolicy::Required | RealtimePolicy::Optional
12700 if self.realtime_state == RealtimeState::Disabled =>
12701 {
12702 self.set_realtime_state(RealtimeState::Disconnected, None);
12703 }
12704 RealtimePolicy::Required | RealtimePolicy::Optional => {}
12705 }
12706 Ok(())
12707 }
12708
12709 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
12712 self.realtime_state = state;
12713 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
12714 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
12715 self.realtime_reason_code = None;
12716 self.realtime_retry_delay_ms = None;
12717 }
12718 }
12719
12720 #[must_use]
12721 fn realtime_connected(&self) -> bool {
12722 self.realtime_state == RealtimeState::Connected
12723 }
12724
12725 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
12726 if self.realtime_policy == RealtimePolicy::Off {
12727 return Err(
12728 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
12729 );
12730 }
12731 if self.realtime_connected() {
12732 return Ok(());
12733 }
12734 self.realtime_state = RealtimeState::Connecting;
12735 match transport.realtime_connect_for_client(&self.client_id) {
12736 Ok(()) => {
12737 self.set_realtime_state(RealtimeState::Connected, None);
12738 Ok(())
12739 }
12740 Err(error) => {
12741 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
12742 Err(format!("{}: {}", error.code, error.message))
12743 }
12744 }
12745 }
12746
12747 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
12748 if self.realtime_state == RealtimeState::Disconnected
12751 || self.realtime_state == RealtimeState::Disabled
12752 {
12753 return;
12754 }
12755 let _ = transport.realtime_close();
12756 self.set_realtime_state(
12757 if self.realtime_policy == RealtimePolicy::Off {
12758 RealtimeState::Disabled
12759 } else {
12760 RealtimeState::Disconnected
12761 },
12762 None,
12763 );
12764 self.presence.clear(); }
12766
12767 pub fn set_presence(
12773 &mut self,
12774 transport: &mut dyn Transport,
12775 scope_key: &str,
12776 doc: Option<&Value>,
12777 ) -> Result<(), String> {
12778 if !self.realtime_connected() {
12779 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
12780 }
12781 let text = encode_presence_publish(scope_key, doc);
12782 transport
12783 .realtime_send(&text)
12784 .map_err(|e| format!("{}: {}", e.code, e.message))
12785 }
12786
12787 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
12789 self.presence
12790 .get(scope_key)
12791 .map(|peers| peers.values().cloned().collect())
12792 .unwrap_or_default()
12793 }
12794
12795 fn apply_presence(
12797 &mut self,
12798 scope_key: String,
12799 kind: Option<PresenceKind>,
12800 actor_id: Option<String>,
12801 client_id: Option<String>,
12802 doc: Option<Value>,
12803 error: Option<String>,
12804 ) {
12805 if error.is_some() {
12808 return;
12809 }
12810 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
12811 return;
12812 };
12813 let peer_key = format!("{actor_id} {client_id}");
12814 match kind {
12815 PresenceKind::Leave => {
12816 if let Some(peers) = self.presence.get_mut(&scope_key) {
12817 peers.remove(&peer_key);
12818 if peers.is_empty() {
12819 self.presence.remove(&scope_key);
12820 }
12821 }
12822 }
12823 _ => {
12824 let doc = match doc {
12825 Some(Value::Object(_)) => doc.unwrap(),
12826 _ => return,
12827 };
12828 self.presence.entry(scope_key).or_default().insert(
12829 peer_key,
12830 PresencePeer {
12831 actor_id,
12832 client_id,
12833 doc,
12834 },
12835 );
12836 }
12837 }
12838 }
12839
12840 pub fn on_realtime_text(&mut self, text: &str) {
12842 match parse_control(text) {
12843 Ok(ControlMessage::Hello { requires_sync, .. }) => {
12844 if requires_sync {
12845 self.set_sync_needed(true, true);
12847 }
12848 }
12849 Ok(ControlMessage::Presence {
12850 scope_key,
12851 kind,
12852 actor_id,
12853 client_id,
12854 doc,
12855 error,
12856 ..
12857 }) => {
12858 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
12859 }
12860 Ok(ControlMessage::Wake { .. }) => {
12861 self.set_sync_needed(true, true);
12863 }
12864 _ => {}
12865 }
12866 }
12867
12868 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
12871 if self.stopped {
12872 return;
12873 }
12874 #[cfg(feature = "bench-internals")]
12875 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
12876 let message = match decode_message(bytes) {
12877 Ok(m) if m.msg_kind == MsgKind::Response => m,
12878 _ => {
12879 self.set_sync_needed(true, true);
12880 return;
12881 }
12882 };
12883 #[cfg(feature = "bench-internals")]
12884 drop(decode_phase);
12885 let mut frames = message.frames.into_iter();
12886 let mut applied_cursor: Option<i64> = None;
12887 let mut any_covered = false;
12888 let mut dropped = false;
12889 while let Some(frame) = frames.next() {
12890 let Frame::SubStart {
12891 id,
12892 status,
12893 effective_scopes,
12894 ..
12895 } = frame
12896 else {
12897 continue;
12898 };
12899 let mut body = Vec::new();
12900 let mut next_cursor: Option<i64> = None;
12901 for inner in frames.by_ref() {
12902 match inner {
12903 Frame::SubEnd {
12904 next_cursor: nc, ..
12905 } => {
12906 next_cursor = Some(nc);
12907 break;
12908 }
12909 Frame::Unknown { .. } => {}
12910 other => body.push(other),
12911 }
12912 }
12913 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
12914 dropped = true;
12915 continue;
12916 };
12917 let sub = &self.subs[sub_index];
12918 if status != SubStatus::Active
12921 || sub.state != SubState::Active
12922 || sub.bootstrap_state.is_some()
12923 || !sub.synced_once
12924 {
12925 dropped = true;
12926 continue;
12927 }
12928 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
12929 any_covered = true;
12931 continue;
12932 }
12933 let previous_effective = self.subs[sub_index].effective.clone();
12934 let previous_cursor = self.subs[sub_index].cursor;
12935 self.subs[sub_index].effective = Some(effective_scopes);
12936 let mut failed = false;
12937 for inner in body {
12938 match inner {
12939 Frame::Commit {
12940 tables, changes, ..
12941 } => {
12942 if self.apply_commit_frame(&tables, &changes).is_err() {
12943 failed = true;
12944 break;
12945 }
12946 }
12947 _ => {
12948 failed = true;
12949 break;
12950 }
12951 }
12952 }
12953 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
12954 self.apply_sub_end(sub_index, cursor, None)
12955 .ok()
12956 .map(|()| cursor)
12957 });
12958 let Some(next_cursor) = committed else {
12959 self.subs[sub_index].effective = previous_effective;
12960 self.subs[sub_index].cursor = previous_cursor;
12961 dropped = true;
12962 continue;
12963 };
12964 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
12965 }
12966 if let Some(cursor) = applied_cursor {
12967 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12969 let _ = transport.realtime_send(&ack);
12970 } else if !any_covered || dropped {
12971 self.set_sync_needed(true, true);
12973 }
12974 }
12975
12976 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
12980 if !self.realtime_connected() {
12981 return;
12982 }
12983 let floor = self
12984 .subs
12985 .iter()
12986 .filter(|s| {
12987 s.state == SubState::Active
12988 && s.bootstrap_state.is_none()
12989 && s.synced_once
12990 && s.cursor >= 0
12991 })
12992 .map(|s| s.cursor)
12993 .min();
12994 if let Some(cursor) = floor {
12995 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12996 let _ = transport.realtime_send(&ack);
12997 }
12998 }
12999}
13000
13001#[cfg(test)]
13002mod previous_version_wiring_tests {
13003 use super::*;
13004 use serde_json::{json, Value};
13005
13006 fn previous_version_schema(version: i32) -> Value {
13007 json!({
13008 "version": version,
13009 "tables": [{
13010 "name": "tasks",
13011 "primaryKey": "id",
13012 "columns": [
13013 { "name": "id", "type": "string", "nullable": false },
13014 { "name": "note", "type": "string", "nullable": true }
13015 ],
13016 "scopes": []
13017 }]
13018 })
13019 }
13020
13021 fn previous_version_limits() -> crate::api::ClientLimits {
13022 crate::api::ClientLimits {
13023 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
13024 enabled: true,
13025 ..Default::default()
13026 }),
13027 ..Default::default()
13028 }
13029 }
13030
13031 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
13033 previous_version_limits()
13034 .previous_version_context
13035 .expect("configured")
13036 }
13037
13038 fn recapture_previous_version(client: &SyncClient) {
13041 let replica_path = client
13042 .previous_version_replica_path()
13043 .expect("file-backed replica");
13044 let outcome = crate::previous_version::capture_previous_version_from_replica(
13045 &client.conn,
13046 &replica_path,
13047 2,
13048 2,
13049 &previous_version_config(),
13050 1,
13051 )
13052 .expect("capture");
13053 assert!(matches!(
13054 outcome,
13055 crate::previous_version::CaptureOutcome::Captured(_)
13056 ));
13057 }
13058
13059 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
13060 Subscription {
13061 id: "wiring-sub".to_owned(),
13062 table: "tasks".to_owned(),
13063 requested: Vec::new(),
13064 params: None,
13065 cursor,
13066 bootstrap_state: bootstrap_state.map(str::to_owned),
13067 state,
13068 reason_code: None,
13069 effective: None,
13070 synced_once: false,
13071 }
13072 }
13073
13074 #[test]
13075 fn previous_version_read_reasons_come_from_live_client_state() {
13076 use crate::previous_version::{
13077 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
13078 };
13079 let path = std::env::temp_dir()
13080 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
13081 .to_string_lossy()
13082 .into_owned();
13083 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
13084 {
13085 let mut setup = SyncClient::open_path(
13086 "wiring".to_owned(),
13087 &previous_version_schema(1),
13088 previous_version_limits(),
13089 &path,
13090 )
13091 .expect("v1 install");
13092 setup
13093 .mutate(vec![Mutation::Upsert {
13094 table: "tasks".to_owned(),
13095 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
13096 base_version: None,
13097 }])
13098 .expect("seed a row");
13099 }
13100 let mut client = SyncClient::open_path(
13101 "wiring".to_owned(),
13102 &previous_version_schema(2),
13103 previous_version_limits(),
13104 &path,
13105 )
13106 .expect("v2 bump captures");
13107 assert!(std::path::Path::new(&container).exists());
13108 let spec = PreviousVersionReadSpec {
13109 table: "tasks".to_owned(),
13110 ..Default::default()
13111 };
13112
13113 recapture_previous_version(&client);
13115 client.lease_state = Some(LeaseState {
13116 lease_id: None,
13117 expires_at_ms: None,
13118 error_code: Some("lease.expired".to_owned()),
13119 });
13120 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13121 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
13122 assert!(!snapshot.available);
13123 assert!(!std::path::Path::new(&container).exists());
13124
13125 recapture_previous_version(&client);
13127 client.set_now_ms(1_000);
13128 client.lease_state = Some(LeaseState {
13129 lease_id: Some("lease".to_owned()),
13130 expires_at_ms: Some(1_000),
13131 error_code: None,
13132 });
13133 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13134 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
13135 assert!(!std::path::Path::new(&container).exists());
13136 client.lease_state = None;
13137
13138 recapture_previous_version(&client);
13140 client.subs.push(subscription(SubState::Revoked, 0, None));
13141 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13142 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
13143 assert!(!std::path::Path::new(&container).exists());
13144 client.subs.clear();
13145
13146 recapture_previous_version(&client);
13149 client
13150 .subs
13151 .push(subscription(SubState::Active, 0, Some("resume")));
13152 assert!(!client.previous_version_coverage_complete());
13153 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13154 assert!(snapshot.available);
13155 assert_eq!(snapshot.previous_version, Some(2));
13156 assert_eq!(snapshot.rows.len(), 1);
13157
13158 recapture_previous_version(&client);
13159 client.subs[0].bootstrap_state = None;
13160 assert!(client.previous_version_coverage_complete());
13161 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13162 assert_eq!(
13163 snapshot.reason,
13164 Some(PreviousVersionReason::CoverageComplete)
13165 );
13166 assert!(!std::path::Path::new(&container).exists());
13167 client.subs.clear();
13168
13169 assert!(!client.previous_version_coverage_complete());
13171
13172 drop(client);
13173 let _ = std::fs::remove_file(&path);
13174 }
13175
13176 #[test]
13183 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
13184 let path = std::env::temp_dir()
13185 .join(format!(
13186 "syncular-prev-lifetime-{}.db",
13187 uuid::Uuid::new_v4()
13188 ))
13189 .to_string_lossy()
13190 .into_owned();
13191 let container = format!(
13192 "{path}{}",
13193 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
13194 );
13195 let exists = || std::path::Path::new(&container).exists();
13196 {
13197 let mut setup = SyncClient::open_path(
13198 "lifetime".to_owned(),
13199 &previous_version_schema(1),
13200 previous_version_limits(),
13201 &path,
13202 )
13203 .expect("v1 install");
13204 setup
13205 .mutate(vec![Mutation::Upsert {
13206 table: "tasks".to_owned(),
13207 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
13208 base_version: None,
13209 }])
13210 .expect("seed a row");
13211 }
13212 let mut client = SyncClient::open_path(
13213 "lifetime".to_owned(),
13214 &previous_version_schema(2),
13215 previous_version_limits(),
13216 &path,
13217 )
13218 .expect("v2 bump captures");
13219 client.set_now_ms(1_000);
13222 assert!(exists());
13223
13224 client.lease_state = Some(LeaseState {
13226 lease_id: None,
13227 expires_at_ms: None,
13228 error_code: Some("lease.expired".to_owned()),
13229 });
13230 client.previous_version_lifetime_check();
13231 assert!(!exists());
13232 client.lease_state = None;
13233
13234 recapture_previous_version(&client);
13237 client.subs.push(subscription(SubState::Active, 0, None));
13238 client.previous_version_lifetime_check();
13239 assert!(!exists());
13240 client.subs.clear();
13241
13242 recapture_previous_version(&client);
13244 client.subs.push(subscription(SubState::Revoked, 0, None));
13245 client.previous_version_lifetime_check();
13246 assert!(!exists());
13247 client.subs.clear();
13248
13249 recapture_previous_version(&client);
13252 client
13253 .subs
13254 .push(subscription(SubState::Active, 0, Some("resume")));
13255 client.previous_version_lifetime_check();
13256 assert!(exists());
13257 client.subs.clear();
13258
13259 drop(client);
13260 let _ = std::fs::remove_file(&path);
13261 let _ = std::fs::remove_file(&container);
13262 let _ = std::fs::remove_file(format!("{container}-wal"));
13263 let _ = std::fs::remove_file(format!("{container}-shm"));
13264 }
13265}