1use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use std::cell::{Cell, RefCell};
12use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
13
14use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
15use rusqlite::{Connection, OpenFlags, OptionalExtension};
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18use sha2::{Digest, Sha256};
19use ssp2::decode::WIRE_VERSION;
20use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
21use ssp2::primitives::RawJson;
22use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
23use ssp2::{
24 decode_message, encode_message, encode_presence_publish, parse_control, ControlMessage,
25 PresenceKind,
26};
27
28use crate::api::{
29 ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30 ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31 ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
32 CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
33 CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
34 DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters, DiagnosticSubscription,
35 FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget,
36 LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation, PresencePeer,
37 PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot, QueryValue,
38 RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RowState, SchemaFloor,
39 SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot, TableChange,
40 WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
41 CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
42 MAX_DIAGNOSTIC_QUERY_FAILURES,
43};
44#[cfg(feature = "bench-internals")]
45use crate::bench::{Phase, Recorder};
46use crate::previous_version::{
47 capture_previous_version_from_replica, first_incompatibility,
48 reconcile_previous_version_at_boot, set_local_schema_descriptor,
49 sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
50 PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
51};
52use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
53use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
54use crate::values::{
55 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
56 encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
57 normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
58};
59
60const DEFAULT_ACCEPT: u8 = 0b0111;
64const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
65const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
66const ACCEPT_SQLITE: u8 = 1 << 2;
67const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
68const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
69
70pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
72const LOCAL_REVISION_KEY: &str = "localRevision";
73const CLIENT_ID_KEY: &str = "clientId";
74const LEASE_STATE_KEY: &str = "leaseState";
75const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
76const LOG_EPOCH_KEY: &str = "logEpoch";
77const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
82const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
83const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
84const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
87const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
90
91#[derive(Debug, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase", deny_unknown_fields)]
93struct PersistedLocalDataRebootstrapReceipt {
94 version: u8,
95 retained_commits: u64,
96 reset_subscriptions: u64,
97}
98
99fn invalid_local_rebootstrap_receipt() -> String {
100 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
101}
102
103fn encode_local_rebootstrap_receipt(
104 retained_commits: usize,
105 reset_subscriptions: usize,
106) -> Result<String, String> {
107 let retained_commits =
108 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
109 let reset_subscriptions =
110 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
111 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
112 return Err(invalid_local_rebootstrap_receipt());
113 }
114 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
115 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
116 retained_commits,
117 reset_subscriptions,
118 })
119 .map_err(|_| invalid_local_rebootstrap_receipt())
120}
121
122fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
123 if value == "v1" {
125 return Ok((0, 0));
126 }
127 let receipt: PersistedLocalDataRebootstrapReceipt =
128 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
129 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
130 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
131 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
132 {
133 return Err(invalid_local_rebootstrap_receipt());
134 }
135 Ok((
136 usize::try_from(receipt.retained_commits)
137 .map_err(|_| invalid_local_rebootstrap_receipt())?,
138 usize::try_from(receipt.reset_subscriptions)
139 .map_err(|_| invalid_local_rebootstrap_receipt())?,
140 ))
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144enum SubState {
145 Active,
146 Revoked,
147 Failed,
148}
149
150#[cfg(test)]
151mod observation_tests {
152 use super::*;
153 use crate::native_transport::HostTransport;
154 use crate::values::encode_row_json;
155 use serde_json::json;
156
157 fn client() -> SyncClient {
158 SyncClient::new(
159 "retry-test".to_owned(),
160 &json!({
161 "version": 1,
162 "tables": [{
163 "name": "tasks",
164 "primaryKey": "id",
165 "columns": [
166 { "name": "id", "type": "string", "nullable": false },
167 { "name": "project_id", "type": "string", "nullable": false }
168 ],
169 "scopes": [{ "pattern": "project:{project_id}" }]
170 }]
171 }),
172 ClientLimits::default(),
173 )
174 .expect("test client")
175 }
176
177 #[cfg(feature = "e2ee")]
178 #[test]
179 fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
180 let schema = json!({
181 "version": 1,
182 "tables": [{
183 "name": "secrets",
184 "primaryKey": "id",
185 "columns": [
186 { "name": "id", "type": "string", "nullable": false },
187 { "name": "encryption_key_id", "type": "string", "nullable": true },
188 { "name": "note", "type": "bytes", "nullable": true,
189 "encrypted": true, "declaredType": "string" }
190 ],
191 "scopes": []
192 }]
193 });
194 let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
195 .expect("test client");
196 let mut config = crate::values::EncryptionConfig::default();
197 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
198 config
199 .key_id_columns
200 .insert("secrets".to_owned(), "encryption_key_id".to_owned());
201 client.set_encryption(config);
202 let commit_id = client
205 .patch(
206 "secrets",
207 "ghost",
208 Map::from_iter([("note".to_owned(), json!("x"))]),
209 None,
210 )
211 .expect("patch records without an author-time encrypt failure");
212 assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
213 assert!(client.drop_unencodable_outbox().unwrap());
215 assert!(client.pending_commit_ids().is_empty());
216 let rejections = client.rejections();
217 assert_eq!(rejections.len(), 1);
218 assert_eq!(rejections[0].code, "client.encrypt_failed");
219 assert_eq!(rejections[0].client_commit_id, commit_id);
220 assert_eq!(
221 client
222 .commit_outcome(&commit_id)
223 .unwrap()
224 .map(|outcome| outcome.status),
225 Some(CommitOutcomeStatus::Rejected)
226 );
227 }
228
229 #[test]
230 fn patch_with_a_scope_column_matches_the_stored_local_row() {
231 let mut client = client();
232 client.create_synced_tables().unwrap();
233 client
234 .conn
235 .execute(
236 "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
237 [],
238 )
239 .unwrap();
240
241 client
244 .patch(
245 "tasks",
246 "t1",
247 Map::from_iter([
248 ("project_id".to_owned(), json!("p1")),
249 ("id".to_owned(), json!("t1")),
250 ]),
251 None,
252 )
253 .expect("an equal scope column is a no-op");
254 let values = client.outbox[0].ops[0]
255 .values
256 .clone()
257 .expect("the patch records values");
258 assert_eq!(values.get("id"), Some(&json!("t1")));
259 assert!(!values.contains_key("project_id"));
260
261 let differing = client.patch(
263 "tasks",
264 "t1",
265 Map::from_iter([("project_id".to_owned(), json!("p2"))]),
266 None,
267 );
268 assert!(differing
269 .expect_err("a differing scope column is rejected")
270 .contains("patch cannot write scope column"));
271
272 let absent = client.patch(
275 "tasks",
276 "ghost",
277 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
278 None,
279 );
280 assert!(absent
281 .expect_err("an absent local row is rejected")
282 .contains("patch cannot write scope column"));
283 assert_eq!(client.pending_commit_ids().len(), 1);
284 }
285
286 #[test]
287 fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
288 let schema = json!({
289 "version": 1,
290 "tables": [
291 {
292 "name": "tenants",
293 "primaryKey": "tenant_id",
294 "columns": [
295 { "name": "tenant_id", "type": "string", "nullable": false },
296 { "name": "body", "type": "string", "nullable": false }
297 ],
298 "scopes": [{ "pattern": "tenant:{tenant_id}" }]
299 },
300 {
301 "name": "buckets",
302 "primaryKey": "id",
303 "columns": [
304 { "name": "id", "type": "string", "nullable": false },
305 { "name": "bucket", "type": "float", "nullable": false },
306 { "name": "body", "type": "string", "nullable": false }
307 ],
308 "scopes": [{ "pattern": "bucket:{bucket}" }]
309 }
310 ]
311 });
312 let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
313 .expect("test client");
314 client.create_synced_tables().unwrap();
315
316 client
320 .patch(
321 "tenants",
322 "t1",
323 Map::from_iter([
324 ("tenant_id".to_owned(), json!("t1")),
325 ("body".to_owned(), json!("created")),
326 ]),
327 None,
328 )
329 .expect("the primary-key scope column is proven equal by construction");
330
331 client
333 .conn
334 .execute(
335 "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
336 [],
337 )
338 .unwrap();
339 client
340 .patch(
341 "buckets",
342 "b1",
343 Map::from_iter([
344 ("bucket".to_owned(), json!(2)),
345 ("body".to_owned(), json!("coerced")),
346 ]),
347 None,
348 )
349 .expect("the stored REAL value coerces from a supplied integer");
350 let values = client.outbox[1].ops[0]
351 .values
352 .clone()
353 .expect("the patch records values");
354 assert!(!values.contains_key("bucket"));
355
356 let differing = client.patch(
358 "buckets",
359 "b1",
360 Map::from_iter([("bucket".to_owned(), json!(3))]),
361 None,
362 );
363 assert!(differing
364 .expect_err("a differing scope value is rejected")
365 .contains("patch cannot write scope column"));
366 }
367
368 #[cfg(feature = "e2ee")]
369 #[test]
370 fn stored_key_fallback_resolves_an_integer_primary_key() {
371 let schema = json!({
372 "version": 1,
373 "tables": [{
374 "name": "patients",
375 "primaryKey": "id",
376 "columns": [
377 { "name": "id", "type": "integer", "nullable": false },
378 { "name": "encryption_key_id", "type": "string", "nullable": true },
379 { "name": "note", "type": "bytes", "nullable": true,
380 "encrypted": true, "declaredType": "string" }
381 ],
382 "scopes": []
383 }]
384 });
385 let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
386 .expect("test client");
387 client.create_synced_tables().unwrap();
388 client
389 .conn
390 .execute(
391 "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
392 VALUES (5, 'k1', NULL, 1)",
393 [],
394 )
395 .unwrap();
396 let mut config = crate::values::EncryptionConfig::default();
397 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
398 config
399 .key_id_columns
400 .insert("patients".to_owned(), "encryption_key_id".to_owned());
401 client.set_encryption(config);
402 let table = client.schema.table("patients").unwrap();
403 let fallback = client
404 .stored_key_fallback(table, "5")
405 .expect("the stored integer key resolves the fallback");
406 assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
407 }
408
409 #[test]
410 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
411 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
412 {"name":"id","type":"string","nullable":false},
413 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
414 for state in ["absent", "cached", "pinned"] {
415 let path = std::env::temp_dir().join(format!(
416 "syncular-blob-stage-{}.sqlite",
417 uuid::Uuid::new_v4()
418 ));
419 let mut client = SyncClient::open_path(
420 "stage-test".into(),
421 &schema,
422 Default::default(),
423 path.to_str().unwrap(),
424 )
425 .unwrap();
426 let bytes = b"atomic stage";
427 if state != "absent" {
428 client
429 .upload_blob(bytes, Some("text/plain".into()), None)
430 .unwrap();
431 if state == "cached" {
432 client
433 .conn
434 .execute("DELETE FROM _syncular_blob_uploads", [])
435 .unwrap();
436 }
437 }
438 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
439 client
440 .conn
441 .execute_batch(
442 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
443 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
444 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
445 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
446 )
447 .unwrap();
448 let error = client
449 .upload_blob(bytes, Some("application/octet-stream".into()), None)
450 .unwrap_err();
451 assert!(error.contains("injected stage failure"), "{state}: {error}");
452 assert!(client.conn.is_autocommit());
453 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
454 drop(client);
455 let mut reopened = SyncClient::open_path(
456 "stage-test".into(),
457 &schema,
458 Default::default(),
459 path.to_str().unwrap(),
460 )
461 .unwrap();
462 let durable = reopened
463 .query("SELECT * FROM _syncular_blobs", &[])
464 .unwrap();
465 reopened
466 .conn
467 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
468 .unwrap();
469 let retried = reopened.upload_blob(bytes, None, None).unwrap();
470 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
471 assert_eq!(
472 reopened
473 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
474 .unwrap(),
475 vec![Map::from_iter([(
476 "blob_id".into(),
477 Value::from(blob_id_for(bytes)),
478 )])]
479 );
480 drop(reopened);
481 std::fs::remove_file(&path).unwrap();
482 assert_eq!(durable, bodies, "{state}: reopened body state");
483 assert_eq!(immediate, bodies, "{state}: immediate body state");
484 }
485 }
486
487 #[test]
488 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
489 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
490 {"name":"id","type":"string","nullable":false},
491 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
492 for fault in [
493 "missing",
494 "body-type",
495 "length",
496 "hash",
497 "pin-metadata",
498 "read",
499 "pin-delete",
500 ] {
501 let path = std::env::temp_dir().join(format!(
502 "syncular-blob-upload-{}.sqlite",
503 uuid::Uuid::new_v4()
504 ));
505 let mut client = SyncClient::open_path(
506 "upload-test".into(),
507 &schema,
508 Default::default(),
509 path.to_str().unwrap(),
510 )
511 .unwrap();
512 let bytes = b"durable upload";
513 let ref_value = client
514 .upload_blob(bytes, Some("text/plain".into()), None)
515 .unwrap();
516 let commit = client
517 .mutate(vec![Mutation::Upsert {
518 table: "attachments".into(),
519 values: Map::from_iter([
520 ("id".into(), Value::from("pending")),
521 (
522 "file".into(),
523 Value::from(format!(
524 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
525 blob_id_for(bytes),
526 bytes.len()
527 )),
528 ),
529 ]),
530 base_version: None,
531 }])
532 .unwrap();
533 client
534 .conn
535 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
536 .unwrap();
537 client.conn.execute_batch(match fault {
538 "missing" => "DELETE FROM _syncular_blobs",
539 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
540 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
541 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
542 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
543 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
544 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
545 }).unwrap();
546 let state = if fault == "read" {
547 Vec::new()
548 } else {
549 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
550 };
551 let mut transport = CountingRealtimeTransport::default();
552 let error = client.flush_blob_uploads(&mut transport).unwrap_err();
553 assert_eq!(
554 error.code,
555 if matches!(fault, "read" | "pin-delete") {
556 "client.failed"
557 } else {
558 "sync.local_corrupt"
559 },
560 "{fault}"
561 );
562 assert_eq!(
563 transport.blob_uploads.len(),
564 usize::from(fault == "pin-delete"),
565 "{fault}: corrupt bytes must never be uploaded"
566 );
567 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
568 if fault != "read" {
569 assert_eq!(
570 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
571 state
572 );
573 } else {
574 client
575 .conn
576 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
577 .unwrap();
578 }
579 drop(client);
580 let mut reopened = SyncClient::open_path(
581 "upload-test".into(),
582 &schema,
583 Default::default(),
584 path.to_str().unwrap(),
585 )
586 .unwrap();
587 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
588 if fault != "read" {
589 assert_eq!(
590 reopened
591 .query("SELECT * FROM _syncular_blobs", &[])
592 .unwrap(),
593 state
594 );
595 }
596 if fault == "pin-delete" {
597 reopened
598 .conn
599 .execute_batch("DROP TRIGGER fail_pin_delete")
600 .unwrap();
601 }
602 if fault == "pin-metadata" {
603 reopened
604 .conn
605 .execute(
606 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
607 [],
608 )
609 .unwrap();
610 }
611 reopened
612 .conn
613 .execute_batch(
614 "DELETE FROM _syncular_blobs;
615 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
616 )
617 .unwrap();
618 transport.blob_uploads.clear();
619 reopened.flush_blob_uploads(&mut transport).unwrap();
620 assert_eq!(
621 transport.blob_uploads,
622 [(blob_id_for(bytes), bytes.to_vec())]
623 );
624 assert_eq!(
625 reopened
626 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
627 .unwrap(),
628 Vec::<Map<String, Value>>::new()
629 );
630 assert_eq!(reopened.pending_commit_ids(), [commit]);
631 assert_eq!(
632 reopened
633 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
634 .unwrap()
635 .unwrap()
636 .bytes,
637 bytes
638 );
639 drop(reopened);
640 std::fs::remove_file(path).unwrap();
641 }
642 }
643
644 #[test]
645 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
646 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
647 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
648 "scopes":[{"pattern":"project:{project_id}"}]}]});
649 for realtime in [false, true] {
650 for fault in [
651 "none",
652 "row",
653 "revision",
654 "commit",
655 "cursor",
656 "error",
657 "missing-end",
658 ] {
659 if realtime && fault == "missing-end" {
660 continue;
661 }
662 let path = std::env::temp_dir()
663 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
664 let mut client = SyncClient::open_path(
665 "frame-test".into(),
666 &schema,
667 Default::default(),
668 path.to_str().unwrap(),
669 )
670 .unwrap();
671 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
672 client
673 .subscribe(
674 "tasks".into(),
675 "tasks".into(),
676 vec![("project_id".into(), vec!["p1".into()])],
677 None,
678 )
679 .unwrap();
680 client.subs[0].cursor = 0;
681 client.subs[0].synced_once = true;
682 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
683 client.persist_sub(&client.subs[0]).unwrap();
684 client.realtime_connected = true;
685 client.drain_change_batches();
686 let mut transport = CountingRealtimeTransport::default();
687 let (_, meta) = client.build_request(false);
688 let mut response = Message {
689 wire_version: WIRE_VERSION,
690 msg_kind: MsgKind::Response,
691 frames: vec![
692 Frame::RespHeader {
693 required_schema_version: None,
694 latest_schema_version: None,
695 log_epoch: Some("epoch-1".into()),
696 reset_required: Some(false),
697 },
698 Frame::SubStart {
699 id: "tasks".into(),
700 status: SubStatus::Active,
701 reason_code: "".into(),
702 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
703 bootstrap: false,
704 },
705 ],
706 };
707 for (index, id) in ["first", "second"].iter().enumerate() {
708 let values = Map::from_iter([
709 ("id".into(), Value::from(*id)),
710 ("project_id".into(), Value::from("p1")),
711 ]);
712 response.frames.push(Frame::Commit {
713 commit_seq: index as i64 + 1,
714 actor_id: "writer".into(),
715 created_at_ms: 1,
716 tables: vec!["tasks".into()],
717 changes: vec![ssp2::model::Change {
718 table_index: 0,
719 row_id: (*id).into(),
720 op: Op::Upsert,
721 row_version: Some(index as i64 + 1),
722 scopes: vec![("project_id".into(), "p1".into())],
723 row: Some(
724 encode_row_json(
725 client.schema.table("tasks").unwrap(),
726 id,
727 &values,
728 &client.encryption,
729 )
730 .unwrap(),
731 ),
732 }],
733 });
734 }
735 response.frames.push(Frame::SubEnd {
736 next_cursor: 2,
737 bootstrap_state: None,
738 });
739 let valid = response.clone();
740 match fault {
741 "row" => {
742 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
743 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
744 },
745 "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(),
746 "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(),
747 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
748 "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});},
749 "missing-end" => {response.frames.truncate(3);},
750 _ => {}
751 }
752 if realtime {
753 client.on_realtime_binary(&mut transport, &encode_message(&response));
754 } else {
755 assert_eq!(
756 matches!(
757 client.process_response(&mut transport, response, &meta),
758 SyncOutcome::Ok(_)
759 ),
760 fault == "none",
761 "{fault}"
762 );
763 }
764 assert!(
765 client.conn.is_autocommit(),
766 "no open transaction after {realtime}/{fault}"
767 );
768 let count = if matches!(fault, "none" | "cursor") {
769 2
770 } else {
771 1
772 };
773 let expected_revision = count + u64::from(realtime && fault != "none");
774 assert_eq!(
775 client.local_revision(),
776 expected_revision,
777 "{realtime}/{fault}"
778 );
779 let batches = client.drain_change_batches();
780 assert_eq!(
781 batches.len(),
782 expected_revision as usize,
783 "{realtime}/{fault}"
784 );
785 let row_batches: Vec<_> = batches
786 .iter()
787 .filter(|batch| !batch.tables.is_empty())
788 .collect();
789 assert_eq!(row_batches.len(), count as usize);
790 assert_eq!(
791 row_batches
792 .iter()
793 .map(|b| b.revision.clone())
794 .collect::<Vec<_>>(),
795 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
796 );
797 for table in ["tasks", "_syncular_base_tasks"] {
798 assert_eq!(
799 client
800 .conn
801 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
802 .get::<_, u64>(0))
803 .unwrap(),
804 count,
805 "{realtime}/{fault}/{table}"
806 );
807 }
808 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
809 assert_eq!(
810 transport
811 .messages
812 .iter()
813 .filter(|m| m.contains("\"ack\""))
814 .count(),
815 usize::from(fault == "none")
816 );
817 if matches!(fault, "revision" | "commit" | "cursor") {
818 client
819 .conn
820 .execute_batch("DROP TRIGGER fail_frame")
821 .unwrap();
822 }
823 drop(client);
824 let mut reopened = SyncClient::open_path(
825 "frame-test".into(),
826 &schema,
827 Default::default(),
828 path.to_str().unwrap(),
829 )
830 .unwrap();
831 assert_eq!(reopened.local_revision(), expected_revision);
832 assert_eq!(
833 reopened
834 .query("SELECT id FROM tasks ORDER BY id", &[])
835 .unwrap()
836 .len(),
837 count as usize
838 );
839 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
840 let (_, meta) = reopened.build_request(false);
841 assert!(matches!(
842 reopened.process_response(&mut transport, valid, &meta),
843 SyncOutcome::Ok(_)
844 ));
845 assert_eq!(
846 reopened
847 .query("SELECT id FROM tasks ORDER BY id", &[])
848 .unwrap()
849 .len(),
850 2
851 );
852 assert_eq!(reopened.subs[0].cursor, 2);
853 drop(reopened);
854 std::fs::remove_file(path).unwrap();
855 }
856 }
857 }
858
859 #[test]
860 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
861 for status in [SubStatus::Reset, SubStatus::Revoked] {
862 for fault in ["subscription", "revision"] {
863 let mut client = client();
864 client
865 .subscribe(
866 "tasks".into(),
867 "tasks".into(),
868 vec![("project_id".into(), vec!["p1".into()])],
869 None,
870 )
871 .unwrap();
872 client.subs[0].cursor = 41;
873 client.subs[0].synced_once = true;
874 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
875 client.persist_sub(&client.subs[0]).unwrap();
876 client
877 .mutate(vec![Mutation::Upsert {
878 table: "tasks".into(),
879 values: Map::from_iter([
880 ("id".into(), Value::from("pending")),
881 ("project_id".into(), Value::from("p1")),
882 ]),
883 base_version: None,
884 }])
885 .unwrap();
886 let ids = client.pending_commit_ids();
887 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
888 let revision = client.local_revision();
889 client.drain_change_batches();
890 let (_, meta) = client.build_request(false);
891 client.conn.execute_batch(if fault == "subscription" {
892 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
893 } else {
894 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
895 }).unwrap();
896 let mut transport = CountingRealtimeTransport::default();
897 let mut report = SyncReport::default();
898 if status == SubStatus::Reset {
900 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
901 }
902 let result = client.process_section(
903 &mut transport,
904 "tasks",
905 status,
906 "sync.scope_revoked",
907 vec![],
908 vec![],
909 Some((42, None)),
910 &meta,
911 &mut report,
912 );
913 assert!(result.is_err(), "{status:?}/{fault}");
914 assert!(client.conn.is_autocommit());
915 assert_eq!(client.subs[0].cursor, 41);
916 assert_eq!(client.subs[0].state, SubState::Active);
917 assert_eq!(client.pending_commit_ids(), ids);
918 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
919 assert!(client.rejections.is_empty());
920 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
921 assert_eq!(client.local_revision(), revision);
922 assert!(client.drain_change_batches().is_empty());
923 assert!(report.revoked.is_empty() && report.resets.is_empty());
924 }
925 }
926 }
927
928 #[test]
929 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
930 let mut client = client();
931 client
932 .subscribe(
933 "tasks".into(),
934 "tasks".into(),
935 vec![("project_id".into(), vec!["p1".into()])],
936 None,
937 )
938 .unwrap();
939 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
940 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
941 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
942 let row = |id: &str| ssp2::segment::SegmentRow {
943 server_version: 1,
944 values: vec![
945 Some(ColumnValue::String(id.into())),
946 Some(ColumnValue::String("p1".into())),
947 ],
948 };
949 let segment = RowsSegment {
950 table: "tasks".into(),
951 schema_version: 1,
952 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
953 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
954 };
955 assert!(client.apply_segment(0, &segment, true).is_err());
956 assert!(client.conn.is_autocommit());
957 assert_eq!(
958 client
959 .query("SELECT id FROM tasks ORDER BY id", &[])
960 .unwrap(),
961 vec![Map::from_iter([("id".into(), Value::from("first"))])]
962 );
963 assert_eq!(client.local_revision(), 1);
964 assert_eq!(client.drain_change_batches().len(), 1);
965 assert_eq!(client.subs[0].cursor, -1);
966 client
967 .conn
968 .execute_batch("DROP TRIGGER fail_block")
969 .unwrap();
970 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
971 assert_eq!(client.local_revision(), 3);
972 assert_eq!(client.drain_change_batches().len(), 2);
973 }
974
975 #[test]
976 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
977 assert_eq!(
978 client().diagnostics_storage().blob_cache_bytes_approx,
979 Some(0)
980 );
981 let client = SyncClient::new(
982 "diagnostic-storage".to_owned(),
983 &json!({
984 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
985 "columns": [{"name": "id", "type": "string", "nullable": false},
986 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
987 }),
988 ClientLimits {
989 blob_cache_max_bytes: Some(5),
990 ..Default::default()
991 },
992 )
993 .unwrap();
994 let empty = client.diagnostics_storage();
995 assert_eq!(empty.status, "healthy");
996 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
997 assert_eq!(empty.retained_outcome_entries, Some(0));
998 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
999 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1000 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1001 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1002 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1003 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1004 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1005 let written = client.diagnostics_storage();
1006 assert_eq!(written.status, "pressure");
1007 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1008 assert_eq!(written.retained_outcome_entries, Some(2));
1009 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1010 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1011 assert_eq!(
1012 written.pressure_reason_code.as_deref(),
1013 Some("client.blob_cache_over_limit")
1014 );
1015 client
1016 .conn
1017 .execute_batch(
1018 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1019 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1020 )
1021 .unwrap();
1022 let staged = client.diagnostics_storage();
1023 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1024 assert_eq!(staged.retained_outcome_entries, Some(0));
1025 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1026 assert_eq!(staged.status, "healthy");
1027 client.conn.execute_batch("ROLLBACK").unwrap();
1028 assert_eq!(client.diagnostics_storage(), written);
1029 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1030 let unreadable = client.diagnostics_storage();
1031 assert_eq!(unreadable.status, "unreadable");
1032 assert!(unreadable.database_bytes_approx.is_none());
1033 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1034 assert!(unreadable.retained_outcome_entries.is_none());
1035 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1036 assert!(unreadable.blob_cache_bytes_approx.is_none());
1037 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1038 let restored = client.diagnostics_storage();
1039 assert_eq!(restored.status, written.status);
1040 assert_eq!(
1041 restored.retained_outcome_bytes_approx,
1042 written.retained_outcome_bytes_approx
1043 );
1044 client.conn.set_prepared_statement_cache_capacity(0);
1045 assert_eq!(client.diagnostics_storage(), restored);
1046 }
1047
1048 #[test]
1049 fn background_retry_deadlines_back_off_and_reset() {
1050 let mut client = client();
1051 client.schedule_background_retry();
1052 assert!(matches!(
1053 client.drain_sync_intents().as_slice(),
1054 [SyncIntent::Background { delay_ms: 250 }]
1055 ));
1056 client.schedule_background_retry();
1057 assert!(matches!(
1058 client.drain_sync_intents().as_slice(),
1059 [SyncIntent::Background { delay_ms: 500 }]
1060 ));
1061 client.reset_background_retry();
1062 client.schedule_background_retry();
1063 assert!(matches!(
1064 client.drain_sync_intents().as_slice(),
1065 [SyncIntent::Background { delay_ms: 250 }]
1066 ));
1067 }
1068
1069 #[test]
1070 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1071 let mut client = client();
1072 client
1073 .subscribe(
1074 "epoch-tasks".to_owned(),
1075 "tasks".to_owned(),
1076 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1077 None,
1078 )
1079 .expect("subscribe");
1080 client.drain_sync_intents();
1081
1082 client
1083 .run_log_epoch_reset("epoch-2")
1084 .expect("reset partition log epoch");
1085
1086 assert!(matches!(
1087 client.drain_sync_intents().as_slice(),
1088 [SyncIntent::Interactive]
1089 ));
1090 }
1091
1092 #[derive(Default)]
1093 struct CountingRealtimeTransport {
1094 connects: usize,
1095 closes: usize,
1096 messages: Vec<String>,
1097 blob_uploads: Vec<(String, Vec<u8>)>,
1098 blob_download: Option<Vec<u8>>,
1099 }
1100
1101 impl Transport for CountingRealtimeTransport {
1102 fn blob_upload(
1103 &mut self,
1104 blob_id: &str,
1105 bytes: &[u8],
1106 _media_type: Option<&str>,
1107 ) -> Result<(), TransportError> {
1108 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1109 Ok(())
1110 }
1111
1112 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1113 self.blob_download
1114 .clone()
1115 .map(BlobDownload::Bytes)
1116 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1117 }
1118
1119 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1120 Err(TransportError::new("sync.transport_failed", "offline"))
1121 }
1122
1123 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1124 Err(TransportError::new("sync.transport_failed", "offline"))
1125 }
1126
1127 fn download_segment(
1128 &mut self,
1129 _request: &SegmentRequest,
1130 _on_progress: &mut dyn FnMut(u64),
1131 ) -> Result<Vec<u8>, TransportError> {
1132 Err(TransportError::new("sync.transport_failed", "offline"))
1133 }
1134
1135 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1136 self.connects += 1;
1137 Ok(())
1138 }
1139
1140 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1141 self.messages.push(text.to_owned());
1142 Ok(())
1143 }
1144
1145 fn realtime_close(&mut self) -> Result<(), TransportError> {
1146 self.closes += 1;
1147 Ok(())
1148 }
1149 }
1150
1151 #[test]
1152 fn blob_results_own_downloaded_bytes() {
1153 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1154 {"name":"id","type":"string","nullable":false},
1155 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1156 let bytes = b"owned typed result".to_vec();
1157 let blob_id = blob_id_for(&bytes);
1158 let mut transport = CountingRealtimeTransport {
1159 blob_download: Some(bytes.clone()),
1160 ..Default::default()
1161 };
1162 let result = {
1163 let mut client =
1164 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1165 .unwrap();
1166 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1167 assert_eq!(result.blob_id, blob_id);
1168 assert_eq!(result.byte_length, bytes.len() as i64);
1169 assert_eq!(result.bytes, bytes);
1170 assert_eq!(result.media_type, None);
1171 assert_eq!(
1172 client
1173 .conn
1174 .query_row(
1175 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1176 rusqlite::params![blob_id],
1177 |row| row.get::<_, i64>(0),
1178 )
1179 .unwrap(),
1180 0
1181 );
1182 result
1183 };
1184 assert_eq!(result.bytes, bytes);
1185
1186 let mut client =
1187 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1188 assert_eq!(
1189 client
1190 .fetch_blob_bytes(&mut transport, "not json")
1191 .unwrap_err(),
1192 (
1193 "client.failed".to_owned(),
1194 "blob ref is not JSON".to_owned()
1195 )
1196 );
1197 }
1198
1199 #[test]
1200 fn visible_blob_references_survive_cache_trimming() {
1201 let schema = json!({"version":1,"tables":[
1202 {"name":"attachments","primaryKey":"id","columns":[
1203 {"name":"id","type":"string","nullable":false},
1204 {"name":"body","type":"blob_ref","nullable":true},
1205 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1206 {"name":"avatars","primaryKey":"id","columns":[
1207 {"name":"id","type":"string","nullable":false},
1208 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1209 ]});
1210 let bytes = b"referenced above cap".to_vec();
1211 let blob_id = blob_id_for(&bytes);
1212 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1213 let mut client = SyncClient::new(
1214 "visible-blob-refs".to_owned(),
1215 &schema,
1216 ClientLimits {
1217 blob_cache_max_bytes: Some(1),
1218 ..Default::default()
1219 },
1220 )
1221 .unwrap();
1222 client.conn.execute(
1223 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1224 rusqlite::params![reference, reference, reference, reference],
1225 ).unwrap();
1226 client
1227 .conn
1228 .execute(
1229 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1230 rusqlite::params![reference],
1231 )
1232 .unwrap();
1233 client.conn.execute(
1234 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1235 [],
1236 ).unwrap();
1237 let mut transport = CountingRealtimeTransport {
1238 blob_download: Some(bytes.clone()),
1239 ..Default::default()
1240 };
1241
1242 assert_eq!(
1243 client
1244 .fetch_blob_bytes(&mut transport, &blob_id)
1245 .unwrap()
1246 .bytes,
1247 bytes
1248 );
1249 let rows = client
1250 .conn
1251 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1252 .unwrap()
1253 .query_map([], |row| row.get::<_, String>(0))
1254 .unwrap()
1255 .collect::<Result<Vec<_>, _>>()
1256 .unwrap();
1257 assert_eq!(rows, vec![blob_id]);
1258 }
1259
1260 #[test]
1261 fn realtime_connection_ownership_is_idempotent() {
1262 let mut client = client();
1263 let mut transport = CountingRealtimeTransport {
1264 connects: 0,
1265 closes: 0,
1266 ..Default::default()
1267 };
1268 client
1269 .connect_realtime(&mut transport)
1270 .expect("first connect");
1271 client
1272 .connect_realtime(&mut transport)
1273 .expect("idempotent connect");
1274 assert_eq!(transport.connects, 1);
1275 client.disconnect_realtime(&mut transport);
1276 client.disconnect_realtime(&mut transport);
1277 assert_eq!(transport.closes, 1);
1278 client
1279 .connect_realtime(&mut transport)
1280 .expect("deliberate reconnect");
1281 assert_eq!(transport.connects, 2);
1282 }
1283
1284 #[test]
1285 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1286 let mut client = client();
1287 client
1288 .subscribe(
1289 "repair-tasks".to_owned(),
1290 "tasks".to_owned(),
1291 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1292 None,
1293 )
1294 .expect("subscribe");
1295 {
1296 let sub = client
1297 .subs
1298 .iter_mut()
1299 .find(|sub| sub.id == "repair-tasks")
1300 .expect("subscription");
1301 sub.cursor = 42;
1302 sub.synced_once = true;
1303 let persisted = sub.clone();
1304 client.persist_sub(&persisted).unwrap();
1305 }
1306 for table in ["_syncular_base_tasks", "tasks"] {
1307 client
1308 .conn
1309 .execute(
1310 &format!(
1311 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1312 ),
1313 rusqlite::params!["server-row", "p1"],
1314 )
1315 .expect("seed server row");
1316 }
1317 let pending = client
1318 .mutate(vec![Mutation::Upsert {
1319 table: "tasks".to_owned(),
1320 values: Map::from_iter([
1321 ("id".to_owned(), Value::from("offline-row")),
1322 ("project_id".to_owned(), Value::from("p1")),
1323 ]),
1324 base_version: None,
1325 }])
1326 .expect("queue offline work");
1327 client.drain_change_batches();
1328 client.drain_sync_intents();
1329
1330 assert_eq!(
1331 client
1332 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1333 rebootstrap_id: "support-case-001".to_owned(),
1334 })
1335 .expect("rebootstrap"),
1336 LocalDataRebootstrapResult {
1337 already_applied: false,
1338 retained_commits: 1,
1339 reset_subscriptions: 1,
1340 }
1341 );
1342 let visible_ids = client
1343 .conn
1344 .prepare("SELECT id FROM tasks ORDER BY id")
1345 .expect("prepare visible ids")
1346 .query_map([], |row| row.get::<_, String>(0))
1347 .expect("query visible ids")
1348 .collect::<Result<Vec<_>, _>>()
1349 .expect("collect visible ids");
1350 assert_eq!(visible_ids, vec!["offline-row"]);
1351 assert_eq!(client.pending_commit_ids(), vec![pending]);
1352 assert_eq!(
1353 client
1354 .subscription_state("repair-tasks")
1355 .expect("subscription")
1356 .cursor,
1357 -1
1358 );
1359 assert!(client.upgrading());
1360 assert!(client.sync_needed());
1361 assert!(matches!(
1362 client.drain_sync_intents().as_slice(),
1363 [SyncIntent::Interactive]
1364 ));
1365 assert_eq!(client.drain_change_batches().len(), 1);
1366
1367 assert_eq!(
1368 client
1369 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1370 rebootstrap_id: "support-case-001".to_owned(),
1371 })
1372 .expect("idempotent retry"),
1373 LocalDataRebootstrapResult {
1374 already_applied: true,
1375 retained_commits: 1,
1376 reset_subscriptions: 1,
1377 }
1378 );
1379 assert!(client.drain_change_batches().is_empty());
1380 }
1381
1382 #[test]
1383 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1384 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1385 assert_eq!(
1386 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1387 (3, 4)
1388 );
1389 assert_eq!(
1390 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1391 (0, 0)
1392 );
1393 for malformed in [
1394 "",
1395 "{}",
1396 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1397 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1398 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1399 ] {
1400 assert_eq!(
1401 decode_local_rebootstrap_receipt(malformed)
1402 .expect_err("malformed receipt must fail"),
1403 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1404 );
1405 }
1406 }
1407
1408 #[test]
1409 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1410 let path = std::env::temp_dir().join(format!(
1411 "syncular-rebootstrap-receipt-{}.db",
1412 uuid::Uuid::new_v4()
1413 ));
1414 let schema = json!({
1415 "version": 1,
1416 "tables": [{
1417 "name": "tasks",
1418 "primaryKey": "id",
1419 "columns": [
1420 { "name": "id", "type": "string", "nullable": false },
1421 { "name": "project_id", "type": "string", "nullable": false }
1422 ],
1423 "scopes": [{ "pattern": "project:{project_id}" }]
1424 }]
1425 });
1426 let path_string = path.to_str().expect("UTF-8 temp path");
1427
1428 {
1429 let mut first = SyncClient::open_path(
1430 "repair-restart-client".to_owned(),
1431 &schema,
1432 ClientLimits::default(),
1433 path_string,
1434 )
1435 .expect("first open");
1436 first
1437 .subscribe(
1438 "repair-tasks".to_owned(),
1439 "tasks".to_owned(),
1440 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1441 None,
1442 )
1443 .expect("subscribe");
1444 first
1445 .mutate(vec![Mutation::Upsert {
1446 table: "tasks".to_owned(),
1447 values: Map::from_iter([
1448 ("id".to_owned(), Value::from("offline-row")),
1449 ("project_id".to_owned(), Value::from("p1")),
1450 ]),
1451 base_version: None,
1452 }])
1453 .expect("queue offline work");
1454 assert_eq!(
1455 first
1456 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1457 rebootstrap_id: "restart-receipt".to_owned(),
1458 })
1459 .expect("first rebootstrap"),
1460 LocalDataRebootstrapResult {
1461 already_applied: false,
1462 retained_commits: 1,
1463 reset_subscriptions: 1,
1464 }
1465 );
1466 }
1467
1468 let mut reopened = SyncClient::open_path(
1469 "repair-restart-client".to_owned(),
1470 &schema,
1471 ClientLimits::default(),
1472 path_string,
1473 )
1474 .expect("reopen");
1475 reopened.drain_change_batches();
1476 reopened.drain_sync_intents();
1477 assert_eq!(
1478 reopened
1479 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1480 rebootstrap_id: "restart-receipt".to_owned(),
1481 })
1482 .expect("receipt replay"),
1483 LocalDataRebootstrapResult {
1484 already_applied: true,
1485 retained_commits: 1,
1486 reset_subscriptions: 1,
1487 }
1488 );
1489 assert!(reopened.drain_change_batches().is_empty());
1490 assert!(reopened.drain_sync_intents().is_empty());
1491 drop(reopened);
1492 std::fs::remove_file(path).expect("remove temp database");
1493 }
1494
1495 #[test]
1496 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1497 let mut malformed = client();
1498 malformed
1499 .subscribe(
1500 "repair-tasks".to_owned(),
1501 "tasks".to_owned(),
1502 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1503 None,
1504 )
1505 .expect("subscribe");
1506 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1507 malformed.drain_change_batches();
1508 malformed.drain_sync_intents();
1509 let malformed_error = malformed
1510 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1511 rebootstrap_id: "malformed".to_owned(),
1512 })
1513 .expect_err("malformed receipt must fail closed");
1514 assert_eq!(
1515 malformed_error,
1516 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1517 );
1518 assert!(!malformed.upgrading());
1519 assert_eq!(
1520 malformed
1521 .subscription_state("repair-tasks")
1522 .expect("unchanged subscription")
1523 .cursor,
1524 -1
1525 );
1526 assert!(malformed.drain_change_batches().is_empty());
1527 assert!(malformed.drain_sync_intents().is_empty());
1528
1529 let mut unreadable = client();
1530 unreadable
1531 .conn
1532 .execute(
1533 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1534 rusqlite::params!["server-row", "p1"],
1535 )
1536 .expect("seed visible row");
1537 unreadable
1538 .conn
1539 .execute("DROP TABLE _syncular_meta", [])
1540 .expect("break marker storage");
1541 let unreadable_error = unreadable
1542 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1543 rebootstrap_id: "unreadable".to_owned(),
1544 })
1545 .expect_err("unreadable marker storage must fail closed");
1546 assert_eq!(
1547 unreadable_error,
1548 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1549 );
1550 let visible_rows = unreadable
1551 .conn
1552 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1553 .expect("visible projection remains");
1554 assert_eq!(visible_rows, 1);
1555 assert!(!unreadable.upgrading());
1556 assert!(unreadable.drain_change_batches().is_empty());
1557 assert!(unreadable.drain_sync_intents().is_empty());
1558 }
1559
1560 #[test]
1561 fn local_rebootstrap_cannot_bypass_schema_floor() {
1562 let mut client = client();
1563 client.set_schema_floor(Some(SchemaFloor {
1564 required_schema_version: Some(2),
1565 latest_schema_version: Some(2),
1566 }));
1567 let error = client
1568 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1569 rebootstrap_id: "blocked-floor".to_owned(),
1570 })
1571 .expect_err("schema floor must block repair");
1572 assert!(error.contains("cannot bypass an active schema-floor stop"));
1573 }
1574
1575 #[test]
1576 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1577 let mut client = client();
1578 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1579 const COMMIT_COUNT: usize = 32;
1580
1581 for index in 0..COMMIT_COUNT {
1582 client
1583 .mutate(vec![Mutation::Upsert {
1584 table: "tasks".to_owned(),
1585 values: Map::from_iter([
1586 ("id".to_owned(), Value::from(format!("task-{index}"))),
1587 ("project_id".to_owned(), Value::from("project-1")),
1588 ]),
1589 base_version: None,
1590 }])
1591 .expect("queue commit");
1592 }
1593
1594 let (_, request_meta) = client.build_request(false);
1595 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1596 let mut frames = vec![Frame::RespHeader {
1597 required_schema_version: None,
1598 latest_schema_version: None,
1599 log_epoch: Some("epoch-1".to_owned()),
1600 reset_required: Some(false),
1601 }];
1602 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1603 |(index, client_commit_id)| Frame::PushResult {
1604 client_commit_id: client_commit_id.clone(),
1605 status: PushStatus::Applied,
1606 commit_seq: Some(index as i64 + 1),
1607 results: vec![OpResult::Applied { op_index: 0 }],
1608 },
1609 ));
1610 let response = Message {
1611 wire_version: WIRE_VERSION,
1612 msg_kind: MsgKind::Response,
1613 frames,
1614 };
1615 let mut transport =
1616 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1617
1618 client.overlay_rebuild_count.set(0);
1619 client.outcome_prune_count.set(0);
1620 let outcome = client.process_response(&mut transport, response, &request_meta);
1621 assert!(matches!(outcome, SyncOutcome::Ok(_)));
1622 assert!(client.pending_commit_ids().is_empty());
1623 assert_eq!(
1624 client.overlay_rebuild_count.get(),
1625 1,
1626 "one response must reconcile its acknowledged commits with one overlay rebuild"
1627 );
1628 assert_eq!(
1629 client.outcome_prune_count.get(),
1630 1,
1631 "one response must enforce outcome retention once"
1632 );
1633 }
1634
1635 #[test]
1636 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1637 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1638 "columns": [
1639 {"name":"id", "type":"string", "nullable":false},
1640 {"name":"project_id", "type":"string", "nullable":false},
1641 {"name":"title", "type":"string", "nullable":false}
1642 ], "scopes": [{"pattern":"project:{project_id}"}],
1643 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1644 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1645 }] });
1646 let mut incremental =
1647 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1648 let mut reference =
1649 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1650 let tables = vec!["tasks".to_owned()];
1651 for index in 0..32 {
1652 let id = format!("task-{}", index % 8);
1653 let values = Map::from_iter([
1654 ("id".to_owned(), Value::from(id.clone())),
1655 (
1656 "project_id".to_owned(),
1657 Value::from(format!("p{}", index % 2)),
1658 ),
1659 ("title".to_owned(), Value::from(format!("needle{index}"))),
1660 ]);
1661 let payload = encode_row_json(
1662 incremental.schema.table("tasks").unwrap(),
1663 &id,
1664 &values,
1665 &incremental.encryption,
1666 )
1667 .unwrap();
1668 let change = ssp2::model::Change {
1669 table_index: 0,
1670 row_id: id,
1671 op: if index % 5 == 0 {
1672 Op::Delete
1673 } else {
1674 Op::Upsert
1675 },
1676 row_version: Some(index + 1),
1677 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1678 row: Some(payload),
1679 };
1680 reference.overlay_dirty.set(true);
1681 for instance in [&mut incremental, &mut reference] {
1682 instance.begin_observation("mirror_test").unwrap();
1683 let mut batch = ChangeAccumulator::default();
1684 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1685 instance
1686 .apply_commit_changes(&tables, std::slice::from_ref(&change))
1687 .unwrap();
1688 instance.rebuild_overlay_if_dirty();
1689 instance.finish_observation("mirror_test", batch).unwrap();
1690 }
1691 for sql in [
1692 "SELECT * FROM tasks ORDER BY id",
1693 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1694 ] {
1695 assert_eq!(
1696 incremental.query(sql, &[]).unwrap(),
1697 reference.query(sql, &[]).unwrap()
1698 );
1699 }
1700 assert_eq!(incremental.local_revision(), reference.local_revision());
1701 assert_eq!(
1702 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1703 serde_json::to_value(reference.drain_change_batches()).unwrap()
1704 );
1705 }
1706 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1707 assert_eq!(reference.overlay_rebuild_count.get(), 32);
1708 }
1709
1710 #[test]
1711 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1712 let cases = [
1713 (
1714 "string",
1715 vec![
1716 json!("1"),
1717 json!("01"),
1718 json!(""),
1719 json!("nul\u{0}id"),
1720 json!("é"),
1721 ],
1722 ),
1723 (
1724 "integer",
1725 vec![
1726 json!(i64::MIN),
1727 json!(-1),
1728 json!(0),
1729 json!(1),
1730 json!(i64::MAX),
1731 ],
1732 ),
1733 ("boolean", vec![json!(false), json!(true)]),
1734 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
1735 ];
1736 for (kind, ids) in cases {
1737 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1738 "columns":[{"name":"id","type":kind,"nullable":false},
1739 {"name":"project_id","type":"string","nullable":false}],
1740 "scopes":[{"pattern":"project:{project_id}"}]}]});
1741 let mut client =
1742 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
1743 for (index, id) in ids.iter().enumerate() {
1744 let table = client.schema.table("tasks").unwrap();
1745 client
1746 .write_base_row(
1747 "tasks",
1748 &vec![
1749 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
1750 Some(ColumnValue::String(format!("p{index}"))),
1751 ],
1752 1,
1753 )
1754 .unwrap();
1755 }
1756 client.rebuild_overlay_if_dirty();
1757 for base in [true, false] {
1758 let full = if base {
1759 base_table("tasks")
1760 } else {
1761 visible_table("tasks")
1762 };
1763 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
1764 for row_id in [
1765 "1",
1766 "01",
1767 "1.0",
1768 "1.0000000000000002",
1769 "+1",
1770 "1e0",
1771 " 1",
1772 "-1",
1773 "0",
1774 "9223372036854775807",
1775 "-9223372036854775808",
1776 "true",
1777 "false",
1778 "",
1779 "nul\u{0}id",
1780 "é",
1781 "null",
1782 "{\"a\":1}",
1783 ] {
1784 let old_sql =
1785 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
1786 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
1787 let old = client
1788 .conn
1789 .prepare(&old_sql)
1790 .unwrap()
1791 .query_map([row_id], |row| row.get::<_, String>(0))
1792 .unwrap()
1793 .collect::<Result<Vec<_>, _>>()
1794 .unwrap();
1795 let new = client
1796 .conn
1797 .prepare_cached(&new_sql)
1798 .unwrap()
1799 .query_map([row_id], |row| row.get::<_, String>(0))
1800 .unwrap()
1801 .collect::<Result<Vec<_>, _>>()
1802 .unwrap();
1803 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
1804 let mut batch = ChangeAccumulator::default();
1805 assert_eq!(
1806 client.record_row_scopes(&mut batch, "tasks", row_id, base),
1807 !old.is_empty()
1808 );
1809 let expected = old.first().map(|scope| {
1810 BTreeMap::from([(
1811 "tasks".to_owned(),
1812 Some(BTreeSet::from([format!("project:{scope}")])),
1813 )])
1814 });
1815 assert_eq!(batch.tables, expected.unwrap_or_default());
1816 client
1817 .conn
1818 .execute_batch("SAVEPOINT lookup_delete")
1819 .unwrap();
1820 if base {
1821 client.delete_base_row("tasks", row_id).unwrap();
1822 } else {
1823 client.apply_outbox_ops(&[OutboxOp {
1824 upsert: false,
1825 table: "tasks".into(),
1826 row_id: row_id.into(),
1827 base_version: None,
1828 values: None,
1829 }]);
1830 }
1831 let remaining: i64 = client
1832 .conn
1833 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
1834 row.get(0)
1835 })
1836 .unwrap();
1837 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
1838 client
1839 .conn
1840 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
1841 .unwrap();
1842 client.overlay_dirty.set(false);
1843 }
1844 for (predicate, expected) in [
1845 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
1846 (predicate, "SEARCH"),
1847 ] {
1848 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
1849 let details = client
1850 .conn
1851 .prepare(&sql)
1852 .unwrap()
1853 .query_map(["1"], |row| row.get::<_, String>(3))
1854 .unwrap()
1855 .collect::<Result<Vec<_>, _>>()
1856 .unwrap();
1857 assert!(
1858 details.iter().any(|detail| detail.contains(expected)),
1859 "{kind}: {details:?}"
1860 );
1861 }
1862 }
1863 }
1864 }
1865
1866 #[test]
1867 fn primary_keys_reject_every_ineligible_column_type() {
1868 for ty in ["float", "bytes", "crdt", "blob_ref"] {
1872 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1873 "columns":[{"name":"id","type":ty,"nullable":false},
1874 {"name":"project_id","type":"string","nullable":false}],
1875 "scopes":[{"pattern":"project:{project_id}"}]}]});
1876 let error =
1877 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
1878 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
1879 Err(error) => error,
1880 };
1881 assert!(
1882 error.contains("has an ineligible column type (§2.4)"),
1883 "{ty}: {error}"
1884 );
1885 }
1886 for ty in ["string", "integer", "boolean", "json"] {
1887 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1888 "columns":[{"name":"id","type":ty,"nullable":false},
1889 {"name":"project_id","type":"string","nullable":false}],
1890 "scopes":[{"pattern":"project:{project_id}"}]}]});
1891 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
1892 .unwrap_or_else(|error| panic!("{ty}: {error}"));
1893 }
1894 }
1895
1896 #[test]
1897 fn pending_row_reconciliation_matches_full_fifo_replay() {
1898 for unique in [false, true] {
1899 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
1900 "name": name, "primaryKey": "id", "columns": [
1901 {"name":"id", "type":"string", "nullable":false},
1902 {"name":"project_id", "type":"string", "nullable":false},
1903 {"name":"title", "type":"string", "nullable":false}],
1904 "scopes": [{"pattern":"project:{project_id}"}],
1905 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
1906 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
1907 })))});
1908 let mut incremental =
1909 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1910 let mut reference =
1911 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1912 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
1913 for instance in [&mut incremental, &mut reference] {
1914 for table in &tables {
1915 for index in 0..12 {
1916 instance
1917 .write_base_row(
1918 table,
1919 &vec![
1920 Some(ColumnValue::String(format!("row{index}"))),
1921 Some(ColumnValue::String("p0".into())),
1922 Some(ColumnValue::String(format!("base{index}"))),
1923 ],
1924 1,
1925 )
1926 .unwrap();
1927 }
1928 }
1929 instance.rebuild_overlay_if_dirty();
1930 }
1931 for index in 0..80 {
1932 let mutations = (0..3)
1933 .map(|offset| {
1934 let table = tables[(index + offset) % 2].clone();
1935 let id = format!("row{}", (index + offset) % 16);
1936 if (index + offset) % 7 == 0 {
1937 Mutation::Delete {
1938 table,
1939 row_id: id,
1940 base_version: None,
1941 }
1942 } else {
1943 Mutation::Upsert {
1944 table,
1945 values: Map::from_iter([
1946 ("id".into(), Value::from(id)),
1947 ("project_id".into(), Value::from(format!("p{}", index % 3))),
1948 (
1949 "title".into(),
1950 Value::from(format!("needle{}", (index + offset) % 16)),
1951 ),
1952 ]),
1953 base_version: None,
1954 }
1955 }
1956 })
1957 .collect::<Vec<_>>();
1958 for instance in [&mut incremental, &mut reference] {
1959 instance.mutate(mutations.clone()).unwrap();
1960 }
1961 let changes = (0..3)
1964 .map(|offset| {
1965 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
1966 let id = format!("row{}", (index + offset) % 16);
1967 let values = Map::from_iter([
1968 ("id".into(), Value::from(id.clone())),
1969 (
1970 "project_id".into(),
1971 Value::from(format!("p{}", (index + 1) % 3)),
1972 ),
1973 (
1974 "title".into(),
1975 Value::from(format!("remote{index}_{offset}")),
1976 ),
1977 ]);
1978 ssp2::model::Change {
1979 table_index: table_index as u16,
1980 row_id: id.clone(),
1981 op: if (index + offset) % 5 == 0 {
1982 Op::Delete
1983 } else {
1984 Op::Upsert
1985 },
1986 row_version: Some(index as i64 + 2),
1987 scopes: vec![],
1988 row: Some(
1989 encode_row_json(
1990 incremental.schema.table(&tables[table_index]).unwrap(),
1991 &id,
1992 &values,
1993 &incremental.encryption,
1994 )
1995 .unwrap(),
1996 ),
1997 }
1998 })
1999 .collect::<Vec<_>>();
2000 reference.overlay_dirty.set(true);
2001 incremental.apply_commit_frame(&tables, &changes).unwrap();
2002 reference.apply_commit_frame(&tables, &changes).unwrap();
2003 for table in &tables {
2004 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2005 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2006 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2007 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2008 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2009 }
2010 }
2011 assert_eq!(incremental.local_revision(), reference.local_revision());
2012 assert_eq!(
2013 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2014 serde_json::to_value(reference.drain_change_batches()).unwrap()
2015 );
2016 }
2017 assert_eq!(
2018 incremental.overlay_rebuild_count.get(),
2019 if unique { 81 } else { 21 }
2020 );
2021 assert_eq!(reference.overlay_rebuild_count.get(), 81);
2022 }
2023 }
2024
2025 #[test]
2026 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2027 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2028 "columns":[{"name":"id", "type":"string", "nullable":false},
2029 {"name":"title", "type":"string", "nullable":false}],
2030 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2031 let mut client =
2032 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2033 for (id, title) in [("a", "original"), ("b", "occupied")] {
2034 client
2035 .write_base_row(
2036 "tasks",
2037 &vec![
2038 Some(ColumnValue::String(id.into())),
2039 Some(ColumnValue::String(title.into())),
2040 ],
2041 1,
2042 )
2043 .unwrap();
2044 }
2045 client.rebuild_overlay_if_dirty();
2046 let pending = client
2047 .mutate(vec![Mutation::Upsert {
2048 table: "tasks".into(),
2049 values: Map::from_iter([
2050 ("id".into(), Value::from("a")),
2051 ("title".into(), Value::from("available")),
2052 ]),
2053 base_version: None,
2054 }])
2055 .unwrap();
2056 assert_eq!(
2057 client
2058 .conn
2059 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2060 .get::<_, String>(
2061 0
2062 ))
2063 .unwrap(),
2064 "available"
2065 );
2066 for (title, expected) in [("available", "original"), ("occupied", "available")] {
2067 let change = ssp2::model::Change {
2068 table_index: 0,
2069 row_id: "b".into(),
2070 op: Op::Upsert,
2071 row_version: Some(2),
2072 scopes: vec![],
2073 row: Some(
2074 encode_row_json(
2075 client.schema.table("tasks").unwrap(),
2076 "b",
2077 &Map::from_iter([
2078 ("id".into(), Value::from("b")),
2079 ("title".into(), Value::from(title)),
2080 ]),
2081 &client.encryption,
2082 )
2083 .unwrap(),
2084 ),
2085 };
2086 client
2087 .apply_commit_frame(&["tasks".into()], &[change])
2088 .unwrap();
2089 assert_eq!(
2090 client
2091 .conn
2092 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2093 .get::<_, String>(
2094 0
2095 ))
2096 .unwrap(),
2097 expected
2098 );
2099 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2100 }
2101 assert_eq!(client.overlay_rebuild_count.get(), 3);
2102 }
2103
2104 #[test]
2105 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2106 for failure in ["visible", "revision", "commit"] {
2107 let mut client = client();
2108 let id = client
2109 .mutate(vec![Mutation::Upsert {
2110 table: "tasks".into(),
2111 values: Map::from_iter([
2112 ("id".into(), Value::from("t1")),
2113 ("project_id".into(), Value::from("pending")),
2114 ]),
2115 base_version: None,
2116 }])
2117 .unwrap();
2118 client.drain_change_batches();
2119 let revision = client.local_revision();
2120 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2121 let trigger = match failure {
2122 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2123 "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"),
2124 _ => "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(),
2125 };
2126 client.conn.execute_batch(&trigger).unwrap();
2127 let change = ssp2::model::Change {
2128 table_index: 0,
2129 row_id: "t1".into(),
2130 op: Op::Upsert,
2131 row_version: Some(2),
2132 scopes: vec![],
2133 row: Some(
2134 encode_row_json(
2135 client.schema.table("tasks").unwrap(),
2136 "t1",
2137 &Map::from_iter([
2138 ("id".into(), Value::from("t1")),
2139 ("project_id".into(), Value::from("remote")),
2140 ]),
2141 &client.encryption,
2142 )
2143 .unwrap(),
2144 ),
2145 };
2146 assert!(client
2147 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
2148 .is_err());
2149 assert!(client.conn.is_autocommit());
2150 assert!(!client.overlay_dirty.get());
2151 assert_eq!(client.local_revision(), revision);
2152 assert!(client.drain_change_batches().is_empty());
2153 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2154 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2155 assert!(client
2156 .query("SELECT * FROM _syncular_base_tasks", &[])
2157 .unwrap()
2158 .is_empty());
2159 client
2160 .conn
2161 .execute_batch("DROP TRIGGER fail_pending")
2162 .unwrap();
2163 client
2164 .apply_commit_frame(&["tasks".into()], &[change])
2165 .unwrap();
2166 assert_eq!(client.local_revision(), revision + 1);
2167 assert_eq!(client.pending_commit_ids(), vec![id]);
2168 let mut expected = before[0].clone();
2171 expected.insert("_syncular_version".to_owned(), Value::from(2));
2172 assert_eq!(
2173 client.query("SELECT * FROM tasks", &[]).unwrap(),
2174 vec![expected]
2175 );
2176 assert_eq!(
2177 client
2178 .conn
2179 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2180 .get::<_, String>(0))
2181 .unwrap(),
2182 "remote"
2183 );
2184 }
2185 }
2186
2187 #[test]
2188 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2189 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2190 "columns": [
2191 {"name":"id", "type":"string", "nullable":false},
2192 {"name":"project_id", "type":"string", "nullable":false},
2193 {"name":"title", "type":"string", "nullable":false}],
2194 "scopes": [{"pattern":"project:{project_id}"}],
2195 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2196 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2197 let mut incremental =
2198 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2199 let mut reference =
2200 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2201 for instance in [&mut incremental, &mut reference] {
2202 for index in 0..16 {
2203 instance
2204 .write_base_row(
2205 "tasks",
2206 &vec![
2207 Some(ColumnValue::String(format!("task{index}"))),
2208 Some(ColumnValue::String("p0".to_owned())),
2209 Some(ColumnValue::String(format!("base{index}"))),
2210 ],
2211 1,
2212 )
2213 .unwrap();
2214 }
2215 instance.rebuild_overlay_if_dirty();
2216 instance.overlay_rebuild_count.set(0);
2217 }
2218 for index in 0..100 {
2219 let mutations = (0..1 + index % 4)
2220 .map(|offset| {
2221 let id = format!("task{}", (index + offset) % 24);
2222 if (index + offset) % 7 == 0 {
2223 Mutation::Delete {
2224 table: "tasks".to_owned(),
2225 row_id: id,
2226 base_version: None,
2227 }
2228 } else {
2229 Mutation::Upsert {
2230 table: "tasks".to_owned(),
2231 values: Map::from_iter([
2232 ("id".to_owned(), Value::from(id)),
2233 (
2234 "project_id".to_owned(),
2235 Value::from(format!("p{}", index % 2)),
2236 ),
2237 (
2238 "title".to_owned(),
2239 Value::from(format!("needle{}", (index + offset) % 24)),
2240 ),
2241 ]),
2242 base_version: None,
2243 }
2244 }
2245 })
2246 .collect::<Vec<_>>();
2247 reference.overlay_dirty.set(true);
2248 incremental.mutate(mutations.clone()).unwrap();
2249 reference.mutate(mutations).unwrap();
2250 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2251 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2252 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2253 }
2254 assert_eq!(incremental.local_revision(), reference.local_revision());
2255 assert_eq!(
2256 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2257 serde_json::to_value(reference.drain_change_batches()).unwrap()
2258 );
2259 }
2260 assert_eq!(incremental.outbox.len(), 100);
2261 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2262 assert_eq!(reference.overlay_rebuild_count.get(), 100);
2263 }
2264
2265 #[test]
2266 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2267 for fail_at in ["outbox", "revision"] {
2268 let mut client = client();
2269 let values = Map::from_iter([
2270 ("id".to_owned(), Value::from("t1")),
2271 ("project_id".to_owned(), Value::from("p1")),
2272 ]);
2273 let mutation = Mutation::Upsert {
2274 table: "tasks".to_owned(),
2275 values,
2276 base_version: None,
2277 };
2278 let trigger = if fail_at == "outbox" {
2279 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2280 } else {
2281 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2282 };
2283 client.conn.execute_batch(&trigger).unwrap();
2284 let revision = client.local_revision();
2285 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2286 assert_eq!(client.local_revision(), revision);
2287 assert!(client.outbox.is_empty());
2288 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2289 assert_eq!(
2290 client
2291 .conn
2292 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2293 .get::<_, i64>(0))
2294 .unwrap(),
2295 0
2296 );
2297 assert!(client.drain_change_batches().is_empty());
2298 assert!(!client.overlay_dirty.get());
2299 client
2300 .conn
2301 .execute_batch("DROP TRIGGER fail_append")
2302 .unwrap();
2303 client.mutate(vec![mutation]).unwrap();
2304 assert_eq!(client.outbox.len(), 1);
2305 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2306 }
2307 }
2308
2309 #[test]
2310 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2311 let mut client = client();
2312 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2313 let values = Map::from_iter([
2314 ("id".to_owned(), Value::from("t1")),
2315 ("project_id".to_owned(), Value::from("p1")),
2316 ]);
2317 let payload = encode_row_json(
2318 client.schema.table("tasks").unwrap(),
2319 "t1",
2320 &values,
2321 &client.encryption,
2322 )
2323 .unwrap();
2324 let first = ssp2::model::Change {
2325 table_index: 0,
2326 row_id: "t1".to_owned(),
2327 op: Op::Upsert,
2328 row_version: Some(1),
2329 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2330 row: Some(payload),
2331 };
2332 let mut invalid = first.clone();
2333 invalid.table_index = 1;
2334 let revision = client.local_revision();
2335 client.begin_observation("failed_mirror").unwrap();
2336 assert!(client
2337 .apply_commit_changes(&tables, &[first, invalid])
2338 .is_err());
2339 client.rollback_observation("failed_mirror");
2340 client.rebuild_overlay_if_dirty();
2341 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2342 assert_eq!(
2343 client
2344 .conn
2345 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2346 .get::<_, i64>(
2347 0
2348 ))
2349 .unwrap(),
2350 0
2351 );
2352 assert_eq!(client.local_revision(), revision);
2353 assert!(client.drain_change_batches().is_empty());
2354 }
2355
2356 #[test]
2357 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2358 for failure in ["journal", "revision", "commit", "later-frame"] {
2359 let mut client = client();
2360 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2361 for id in ["first", "second"] {
2362 client
2363 .mutate(vec![Mutation::Upsert {
2364 table: "tasks".to_owned(),
2365 values: Map::from_iter([
2366 ("id".to_owned(), Value::from(id)),
2367 ("project_id".to_owned(), Value::from("p1")),
2368 ]),
2369 base_version: None,
2370 }])
2371 .unwrap();
2372 }
2373 let (_, meta) = client.build_request(false);
2374 client
2376 .mutate(vec![Mutation::Upsert {
2377 table: "tasks".to_owned(),
2378 values: Map::from_iter([
2379 ("id".to_owned(), Value::from("later")),
2380 ("project_id".to_owned(), Value::from("p1")),
2381 ]),
2382 base_version: None,
2383 }])
2384 .unwrap();
2385 let ids = client.pending_commit_ids();
2386 let rows = client
2387 .query("SELECT * FROM tasks ORDER BY id", &[])
2388 .unwrap();
2389 let revision = client.local_revision();
2390 client.drain_change_batches();
2391 if failure != "later-frame" {
2392 client.conn.execute_batch(match failure {
2393 "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",
2394 "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",
2395 _ => "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",
2396 }).unwrap();
2397 }
2398 let mut response = Message {
2399 wire_version: WIRE_VERSION,
2400 msg_kind: MsgKind::Response,
2401 frames: vec![Frame::RespHeader {
2402 required_schema_version: None,
2403 latest_schema_version: None,
2404 log_epoch: Some("epoch-1".to_owned()),
2405 reset_required: Some(false),
2406 }],
2407 };
2408 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2409 response.frames.push(Frame::PushResult {
2410 client_commit_id: id.to_owned(),
2411 status: PushStatus::Applied,
2412 commit_seq: Some(1),
2413 results: vec![OpResult::Applied { op_index: 0 }],
2414 });
2415 }
2416 if failure == "later-frame" {
2417 response.frames.push(Frame::Error {
2418 code: "sync.invalid_request".to_owned(),
2419 message: "later error".to_owned(),
2420 category: "protocol".to_owned(),
2421 retryable: false,
2422 recommended_action: "retry".to_owned(),
2423 details: None,
2424 });
2425 }
2426 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2427 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2428 assert!(
2429 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2430 );
2431 if failure == "later-frame" {
2432 assert_eq!(client.pending_commit_ids(), ids[2..]);
2433 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2434 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2435 let batches = client.drain_change_batches();
2436 assert_eq!(batches.len(), 1);
2437 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2438 assert_eq!(client.local_revision(), revision + 1);
2439 response.frames.pop();
2440 } else {
2441 assert_eq!(client.pending_commit_ids(), ids);
2442 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2443 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2444 assert_eq!(client.local_revision(), revision);
2445 assert!(client.drain_change_batches().is_empty());
2446 assert_eq!(
2447 client
2448 .query("SELECT * FROM tasks ORDER BY id", &[])
2449 .unwrap(),
2450 rows
2451 );
2452 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2453 }
2454 for frame in &mut response.frames {
2455 if let Frame::PushResult { status, .. } = frame {
2456 *status = PushStatus::Cached;
2457 }
2458 }
2459 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2460 else {
2461 panic!("retry failed");
2462 };
2463 assert_eq!(
2464 report.applied,
2465 if failure == "later-frame" {
2466 vec![]
2467 } else {
2468 ids[..2].to_vec()
2469 }
2470 );
2471 assert_eq!(client.pending_commit_ids(), ids[2..]);
2472 let batches = client.drain_change_batches();
2473 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2474 if let Some(batch) = batches.first() {
2475 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2476 }
2477 assert_eq!(client.local_revision(), revision + 1);
2478 }
2479 }
2480
2481 #[test]
2482 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2483 for failure in ["revision", "journal", "commit"] {
2484 for status in ["applied", "cached", "rejected", "conflict"] {
2485 let mut client = client();
2486 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2487 for id in ["first", "later"] {
2488 client
2489 .mutate(vec![Mutation::Upsert {
2490 table: "tasks".to_owned(),
2491 values: Map::from_iter([
2492 ("id".to_owned(), Value::from(id)),
2493 ("project_id".to_owned(), Value::from("p1")),
2494 ]),
2495 base_version: None,
2496 }])
2497 .unwrap();
2498 }
2499 let (_, meta) = client.build_request(false);
2500 let ids = client.pending_commit_ids();
2501 let rows = client
2502 .query("SELECT * FROM tasks ORDER BY id", &[])
2503 .unwrap();
2504 let revision = client.local_revision();
2505 client.drain_change_batches();
2506 let (push_status, results) = match status {
2507 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2508 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2509 "conflict" => (
2510 PushStatus::Rejected,
2511 vec![OpResult::Conflict {
2512 op_index: 0,
2513 code: "sync.version_conflict".to_owned(),
2514 message: "conflict".to_owned(),
2515 server_version: 1,
2516 server_row: encode_row_json(
2517 client.schema.table("tasks").unwrap(),
2518 "first",
2519 &Map::from_iter([
2520 ("id".to_owned(), Value::from("first")),
2521 ("project_id".to_owned(), Value::from("p1")),
2522 ]),
2523 &client.encryption,
2524 )
2525 .unwrap(),
2526 conflict_columns: Vec::new(),
2527 }],
2528 ),
2529 _ => (
2530 PushStatus::Rejected,
2531 vec![OpResult::Error {
2532 op_index: 0,
2533 code: "sync.validation_failed".to_owned(),
2534 message: "rejected".to_owned(),
2535 retryable: false,
2536 }],
2537 ),
2538 };
2539 let response = Message {
2540 wire_version: WIRE_VERSION,
2541 msg_kind: MsgKind::Response,
2542 frames: vec![
2543 Frame::RespHeader {
2544 required_schema_version: None,
2545 latest_schema_version: None,
2546 log_epoch: Some("epoch-1".to_owned()),
2547 reset_required: Some(false),
2548 },
2549 Frame::PushResult {
2550 client_commit_id: ids[0].clone(),
2551 status: push_status,
2552 commit_seq: Some(1),
2553 results,
2554 },
2555 ],
2556 };
2557 client.conn.execute_batch(match failure {
2558 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2559 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2560 _ => "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",
2561 }).unwrap();
2562 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2563 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2564 assert_eq!(
2565 client.pending_commit_ids(),
2566 ids,
2567 "{failure}/{status}: pending identity"
2568 );
2569 assert_eq!(
2570 client.local_revision(),
2571 revision,
2572 "{failure}/{status}: revision"
2573 );
2574 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2575 assert!(client.conflicts().is_empty());
2576 assert!(client.rejections().is_empty());
2577 assert!(client.drain_change_batches().is_empty());
2578 assert_eq!(
2579 client
2580 .query("SELECT * FROM tasks ORDER BY id", &[])
2581 .unwrap(),
2582 rows
2583 );
2584 assert!(
2585 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2586 "{failure}/{status}: failed round"
2587 );
2588 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2589 assert!(matches!(
2590 client.process_response(&mut transport, response, &meta),
2591 SyncOutcome::Ok(_)
2592 ));
2593 assert_eq!(client.pending_commit_ids(), ids[1..]);
2594 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2595 assert_eq!(client.drain_change_batches().len(), 1);
2596 }
2597 }
2598 }
2599
2600 #[test]
2601 fn mixed_push_results_reconcile_and_prune_once_per_response() {
2602 let mut client = client();
2603 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2604 for index in 0..4 {
2605 client
2606 .mutate(vec![Mutation::Upsert {
2607 table: "tasks".to_owned(),
2608 values: Map::from_iter([
2609 ("id".to_owned(), Value::from(format!("task-{index}"))),
2610 ("project_id".to_owned(), Value::from("project-1")),
2611 ]),
2612 base_version: None,
2613 }])
2614 .expect("queue commit");
2615 }
2616
2617 let (_, request_meta) = client.build_request(false);
2618 let ids = &request_meta.pushed_ids;
2619 assert_eq!(ids.len(), 4);
2620 let response = Message {
2621 wire_version: WIRE_VERSION,
2622 msg_kind: MsgKind::Response,
2623 frames: vec![
2624 Frame::RespHeader {
2625 required_schema_version: None,
2626 latest_schema_version: None,
2627 log_epoch: Some("epoch-1".to_owned()),
2628 reset_required: Some(false),
2629 },
2630 Frame::PushResult {
2631 client_commit_id: ids[0].clone(),
2632 status: PushStatus::Applied,
2633 commit_seq: Some(1),
2634 results: vec![OpResult::Applied { op_index: 0 }],
2635 },
2636 Frame::PushResult {
2637 client_commit_id: ids[1].clone(),
2638 status: PushStatus::Cached,
2639 commit_seq: Some(2),
2640 results: vec![OpResult::Applied { op_index: 0 }],
2641 },
2642 Frame::PushResult {
2643 client_commit_id: ids[2].clone(),
2644 status: PushStatus::Rejected,
2645 commit_seq: None,
2646 results: vec![OpResult::Error {
2647 op_index: 0,
2648 code: "sync.validation_failed".to_owned(),
2649 message: "rejected".to_owned(),
2650 retryable: false,
2651 }],
2652 },
2653 Frame::PushResult {
2654 client_commit_id: ids[3].clone(),
2655 status: PushStatus::Rejected,
2656 commit_seq: None,
2657 results: vec![OpResult::Error {
2658 op_index: 0,
2659 code: "sync.idempotency_cache_miss".to_owned(),
2660 message: "retry".to_owned(),
2661 retryable: true,
2662 }],
2663 },
2664 ],
2665 };
2666 let mut transport =
2667 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2668
2669 client.overlay_rebuild_count.set(0);
2670 client.outcome_prune_count.set(0);
2671 let outcome = client.process_response(&mut transport, response, &request_meta);
2672 let SyncOutcome::Ok(report) = outcome else {
2673 panic!("mixed push-result response failed");
2674 };
2675
2676 assert_eq!(report.applied, ids[..2]);
2677 assert_eq!(report.rejected, ids[2..3]);
2678 assert_eq!(report.retryable, ids[3..4]);
2679 assert_eq!(client.pending_commit_ids(), ids[3..4]);
2680 assert_eq!(
2681 client
2682 .query("SELECT id FROM tasks ORDER BY id", &[])
2683 .expect("query visible overlay"),
2684 vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2685 );
2686 assert_eq!(client.overlay_rebuild_count.get(), 1);
2687 assert_eq!(client.outcome_prune_count.get(), 1);
2688 }
2689
2690 #[test]
2691 fn secondary_unique_collision_preserves_existing_synced_row() {
2692 let client = SyncClient::new(
2693 "unique-upsert-test".to_owned(),
2694 &json!({
2695 "version": 1,
2696 "tables": [{
2697 "name": "tasks",
2698 "primaryKey": "id",
2699 "columns": [
2700 { "name": "id", "type": "string", "nullable": false },
2701 { "name": "project_id", "type": "string", "nullable": false },
2702 { "name": "title", "type": "string", "nullable": false }
2703 ],
2704 "scopes": [{ "pattern": "project:{project_id}" }],
2705 "indexes": [{
2706 "name": "tasks_by_project_title",
2707 "columns": ["project_id", "title"],
2708 "unique": true
2709 }]
2710 }]
2711 }),
2712 ClientLimits::default(),
2713 )
2714 .expect("test client");
2715 let table = client.schema.table("tasks").expect("tasks table");
2716 let sql = client.insert_row_sql(&base_table("tasks"), table);
2717
2718 client
2719 .conn
2720 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2721 .expect("insert first row");
2722 client
2723 .conn
2724 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
2725 .expect("update same primary key");
2726 client
2727 .conn
2728 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
2729 .expect("insert second row");
2730 assert!(client
2731 .conn
2732 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
2733 .is_err());
2734
2735 let rows = client
2736 .conn
2737 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
2738 .expect("prepare rows")
2739 .query_map([], |row| {
2740 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2741 })
2742 .expect("query rows")
2743 .collect::<Result<Vec<_>, _>>()
2744 .expect("collect rows");
2745 assert_eq!(
2746 rows,
2747 vec![
2748 ("t1".to_owned(), "updated".to_owned()),
2749 ("t2".to_owned(), "original".to_owned())
2750 ]
2751 );
2752 }
2753
2754 #[test]
2755 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
2756 let path = std::env::temp_dir().join(format!(
2757 "syncular-startup-intent-{}.db",
2758 uuid::Uuid::new_v4()
2759 ));
2760 let schema = json!({
2761 "version": 1,
2762 "tables": [{
2763 "name": "tasks",
2764 "primaryKey": "id",
2765 "columns": [
2766 { "name": "id", "type": "string", "nullable": false },
2767 { "name": "project_id", "type": "string", "nullable": false }
2768 ],
2769 "scopes": [{ "pattern": "project:{project_id}" }]
2770 }]
2771 });
2772
2773 {
2774 let mut first = SyncClient::open_path_with_identity(
2775 None,
2776 &schema,
2777 ClientLimits::default(),
2778 path.to_str().expect("UTF-8 temp path"),
2779 )
2780 .expect("first open");
2781 first
2782 .set_window(
2783 &WindowBase {
2784 table: "tasks".to_owned(),
2785 variable: "project_id".to_owned(),
2786 fixed_scopes: Vec::new(),
2787 params: None,
2788 },
2789 &["persisted".to_owned()],
2790 )
2791 .expect("persist window");
2792 }
2793
2794 let mut reopened = SyncClient::open_path_with_identity(
2795 None,
2796 &schema,
2797 ClientLimits::default(),
2798 path.to_str().expect("UTF-8 temp path"),
2799 )
2800 .expect("reopen");
2801 assert!(reopened.sync_needed());
2802 assert!(matches!(
2803 reopened.drain_sync_intents().as_slice(),
2804 [SyncIntent::Interactive]
2805 ));
2806 drop(reopened);
2807 std::fs::remove_file(path).expect("remove temp database");
2808 }
2809
2810 #[test]
2811 fn reopening_preserves_immutable_subscription_identity_and_progress() {
2812 let path = std::env::temp_dir().join(format!(
2813 "syncular-subscription-identity-{}.db",
2814 uuid::Uuid::new_v4()
2815 ));
2816 let schema = json!({
2817 "version": 1,
2818 "tables": [
2819 {
2820 "name": "tasks",
2821 "primaryKey": "id",
2822 "columns": [
2823 { "name": "id", "type": "string", "nullable": false },
2824 { "name": "project_id", "type": "string", "nullable": false }
2825 ],
2826 "scopes": [{ "pattern": "project:{project_id}" }]
2827 },
2828 {
2829 "name": "docs",
2830 "primaryKey": "id",
2831 "columns": [
2832 { "name": "id", "type": "string", "nullable": false },
2833 { "name": "org_id", "type": "string", "nullable": false },
2834 { "name": "project_id", "type": "string", "nullable": false }
2835 ],
2836 "scopes": [
2837 { "pattern": "org:{org_id}" },
2838 { "pattern": "project:{projectId}", "column": "project_id" }
2839 ]
2840 }
2841 ]
2842 });
2843
2844 {
2845 let mut first = SyncClient::open_path_with_identity(
2846 None,
2847 &schema,
2848 ClientLimits::default(),
2849 path.to_str().expect("UTF-8 temp path"),
2850 )
2851 .expect("first open");
2852 first
2853 .subscribe(
2854 "stable-subscription".to_owned(),
2855 "tasks".to_owned(),
2856 vec![(
2857 "project_id".to_owned(),
2858 vec!["p2".to_owned(), "p1".to_owned()],
2859 )],
2860 Some(r#"{"view":"v1"}"#.to_owned()),
2861 )
2862 .expect("persist subscription");
2863 let persisted = {
2864 let subscription = first
2865 .subs
2866 .iter_mut()
2867 .find(|subscription| subscription.id == "stable-subscription")
2868 .expect("subscription");
2869 subscription.cursor = 41;
2870 subscription.bootstrap_state = Some("resume-token".to_owned());
2871 subscription.effective = Some(vec![(
2872 "project_id".to_owned(),
2873 vec!["p1".to_owned(), "p2".to_owned()],
2874 )]);
2875 subscription.synced_once = true;
2876 subscription.clone()
2877 };
2878 first.persist_sub(&persisted).unwrap();
2879 }
2880
2881 let mut reopened = SyncClient::open_path_with_identity(
2882 None,
2883 &schema,
2884 ClientLimits::default(),
2885 path.to_str().expect("UTF-8 temp path"),
2886 )
2887 .expect("reopen");
2888 let progress = reopened
2889 .subscription_state("stable-subscription")
2890 .expect("persisted state");
2891 assert_eq!(progress.cursor, 41);
2892 assert!(progress.has_resume_token);
2893
2894 reopened
2895 .subscribe(
2896 "stable-subscription".to_owned(),
2897 "tasks".to_owned(),
2898 vec![(
2899 "project_id".to_owned(),
2900 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
2901 )],
2902 Some(r#"{"view":"v1"}"#.to_owned()),
2903 )
2904 .expect("canonical intent is idempotent");
2905 assert_eq!(
2906 reopened
2907 .subscription_state("stable-subscription")
2908 .expect("unchanged state")
2909 .cursor,
2910 progress.cursor
2911 );
2912
2913 for (table, scopes, params) in [
2914 (
2915 "tasks",
2916 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2917 Some(r#"{"view":"v1"}"#.to_owned()),
2918 ),
2919 (
2920 "tasks",
2921 vec![(
2922 "project_id".to_owned(),
2923 vec!["p2".to_owned(), "p1".to_owned()],
2924 )],
2925 Some(r#"{"view":"v2"}"#.to_owned()),
2926 ),
2927 (
2928 "docs",
2929 vec![
2930 ("org_id".to_owned(), vec!["o1".to_owned()]),
2931 ("projectId".to_owned(), vec!["p1".to_owned()]),
2932 ],
2933 Some(r#"{"view":"v1"}"#.to_owned()),
2934 ),
2935 ] {
2936 let error = reopened
2937 .subscribe(
2938 "stable-subscription".to_owned(),
2939 table.to_owned(),
2940 scopes,
2941 params,
2942 )
2943 .expect_err("identity rebind must fail");
2944 assert!(error.starts_with("client.subscription_intent_mismatch:"));
2945 assert_eq!(
2946 reopened
2947 .subscription_state("stable-subscription")
2948 .expect("unchanged state")
2949 .cursor,
2950 progress.cursor
2951 );
2952 }
2953
2954 drop(reopened);
2955 std::fs::remove_file(path).expect("remove temp database");
2956 }
2957
2958 #[test]
2959 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
2960 let path = std::env::temp_dir().join(format!(
2961 "syncular-satisfied-schema-floor-{}.db",
2962 uuid::Uuid::new_v4()
2963 ));
2964 let schema = json!({
2965 "version": 23,
2966 "tables": [{
2967 "name": "tasks",
2968 "primaryKey": "id",
2969 "columns": [
2970 { "name": "id", "type": "string", "nullable": false },
2971 { "name": "project_id", "type": "string", "nullable": false }
2972 ],
2973 "scopes": [{ "pattern": "project:{project_id}" }]
2974 }]
2975 });
2976
2977 {
2978 let mut first = SyncClient::open_path_with_identity(
2979 None,
2980 &schema,
2981 ClientLimits::default(),
2982 path.to_str().expect("UTF-8 temp path"),
2983 )
2984 .expect("first open");
2985 first
2986 .subscribe(
2987 "tasks".to_owned(),
2988 "tasks".to_owned(),
2989 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2990 None,
2991 )
2992 .expect("persist subscription");
2993 first.set_schema_floor(Some(SchemaFloor {
2994 required_schema_version: Some(22),
2995 latest_schema_version: Some(22),
2996 }));
2997 }
2998
2999 let mut reopened = SyncClient::open_path_with_identity(
3000 None,
3001 &schema,
3002 ClientLimits::default(),
3003 path.to_str().expect("UTF-8 temp path"),
3004 )
3005 .expect("reopen");
3006 assert!(reopened.schema_floor().is_none());
3007 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3008 assert!(reopened.sync_needed());
3009 assert!(matches!(
3010 reopened.drain_sync_intents().as_slice(),
3011 [SyncIntent::Interactive]
3012 ));
3013 drop(reopened);
3014 std::fs::remove_file(path).expect("remove temp database");
3015 }
3016
3017 #[test]
3018 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3019 let path = std::env::temp_dir().join(format!(
3020 "syncular-unsatisfied-schema-floor-{}.db",
3021 uuid::Uuid::new_v4()
3022 ));
3023 let schema = json!({
3024 "version": 1,
3025 "tables": [{
3026 "name": "tasks",
3027 "primaryKey": "id",
3028 "columns": [
3029 { "name": "id", "type": "string", "nullable": false },
3030 { "name": "project_id", "type": "string", "nullable": false }
3031 ],
3032 "scopes": [{ "pattern": "project:{project_id}" }]
3033 }]
3034 });
3035
3036 {
3037 let mut first = SyncClient::open_path_with_identity(
3038 None,
3039 &schema,
3040 ClientLimits::default(),
3041 path.to_str().expect("UTF-8 temp path"),
3042 )
3043 .expect("first open");
3044 first
3045 .subscribe(
3046 "tasks".to_owned(),
3047 "tasks".to_owned(),
3048 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3049 None,
3050 )
3051 .expect("persist subscription");
3052 first.set_schema_floor(Some(SchemaFloor {
3053 required_schema_version: Some(2),
3054 latest_schema_version: Some(2),
3055 }));
3056 }
3057
3058 let reopened = SyncClient::open_path_with_identity(
3059 None,
3060 &schema,
3061 ClientLimits::default(),
3062 path.to_str().expect("UTF-8 temp path"),
3063 )
3064 .expect("reopen");
3065 assert_eq!(
3066 reopened.schema_floor(),
3067 Some(&SchemaFloor {
3068 required_schema_version: Some(2),
3069 latest_schema_version: Some(2),
3070 })
3071 );
3072 assert!(!reopened.sync_needed());
3073 drop(reopened);
3074 std::fs::remove_file(path).expect("remove temp database");
3075 }
3076
3077 #[test]
3078 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3079 let path = std::env::temp_dir().join(format!(
3080 "syncular-indexed-column-bump-{}.db",
3081 uuid::Uuid::new_v4()
3082 ));
3083 let old_schema = json!({
3084 "version": 1,
3085 "tables": [
3086 {
3087 "name": "tasks",
3088 "primaryKey": "id",
3089 "columns": [
3090 { "name": "id", "type": "string", "nullable": false },
3091 { "name": "project_id", "type": "string", "nullable": false }
3092 ],
3093 "scopes": [{ "pattern": "project:{project_id}" }]
3094 },
3095 {
3096 "name": "legacy",
3097 "primaryKey": "id",
3098 "columns": [
3099 { "name": "id", "type": "string", "nullable": false },
3100 { "name": "project_id", "type": "string", "nullable": false }
3101 ],
3102 "scopes": [{ "pattern": "project:{project_id}" }]
3103 }
3104 ]
3105 });
3106 {
3107 let mut first = SyncClient::open_path_with_identity(
3108 None,
3109 &old_schema,
3110 ClientLimits::default(),
3111 path.to_str().expect("UTF-8 temp path"),
3112 )
3113 .expect("open old schema");
3114 first
3115 .subscribe(
3116 "legacy-sub".to_owned(),
3117 "legacy".to_owned(),
3118 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3119 None,
3120 )
3121 .expect("persist legacy subscription");
3122 }
3123
3124 let new_schema = json!({
3125 "version": 2,
3126 "tables": [{
3127 "name": "tasks",
3128 "primaryKey": "id",
3129 "columns": [
3130 { "name": "id", "type": "string", "nullable": false },
3131 { "name": "project_id", "type": "string", "nullable": false },
3132 { "name": "facility_membership_id", "type": "string", "nullable": true }
3133 ],
3134 "scopes": [{ "pattern": "project:{project_id}" }],
3135 "indexes": [{
3136 "name": "tasks_by_membership",
3137 "columns": ["project_id", "facility_membership_id"],
3138 "unique": false
3139 }]
3140 }]
3141 });
3142 let upgraded = SyncClient::open_path_with_identity(
3143 None,
3144 &new_schema,
3145 ClientLimits::default(),
3146 path.to_str().expect("UTF-8 temp path"),
3147 )
3148 .expect("open upgraded schema");
3149 let columns = upgraded
3150 .conn
3151 .prepare("PRAGMA table_info(tasks)")
3152 .expect("prepare columns")
3153 .query_map([], |row| row.get::<_, String>(1))
3154 .expect("query columns")
3155 .collect::<Result<Vec<_>, _>>()
3156 .expect("collect columns");
3157 assert!(columns.contains(&"facility_membership_id".to_owned()));
3158 let index_count: i64 = upgraded
3159 .conn
3160 .query_row(
3161 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3162 [],
3163 |row| row.get(0),
3164 )
3165 .expect("query index");
3166 assert_eq!(index_count, 1);
3167 assert!(upgraded.subscription_state("legacy-sub").is_none());
3168 drop(upgraded);
3169 std::fs::remove_file(path).expect("remove temp database");
3170 }
3171
3172 #[test]
3173 fn migrates_pre_envelope_outcome_journal_additively() {
3174 let path = std::env::temp_dir().join(format!(
3175 "syncular-outcome-migration-{}.db",
3176 uuid::Uuid::new_v4()
3177 ));
3178 let conn = Connection::open(&path).expect("open legacy database");
3179 conn.execute_batch(
3180 "CREATE TABLE _syncular_commit_outcomes (
3181 seq INTEGER PRIMARY KEY AUTOINCREMENT,
3182 client_commit_id TEXT NOT NULL UNIQUE,
3183 status TEXT NOT NULL,
3184 recorded_at_ms INTEGER NOT NULL,
3185 results_json TEXT NOT NULL,
3186 resolution TEXT NOT NULL DEFAULT 'active',
3187 resolved_at_ms INTEGER,
3188 replacement_client_commit_id TEXT);",
3189 )
3190 .expect("create legacy outcome journal");
3191 drop(conn);
3192 let schema = json!({
3193 "version": 1,
3194 "tables": [{
3195 "name": "tasks",
3196 "primaryKey": "id",
3197 "columns": [
3198 { "name": "id", "type": "string", "nullable": false },
3199 { "name": "project_id", "type": "string", "nullable": false }
3200 ],
3201 "scopes": [{ "pattern": "project:{project_id}" }]
3202 }]
3203 });
3204 let client = SyncClient::open_path(
3205 "migration-native".to_owned(),
3206 &schema,
3207 ClientLimits::default(),
3208 path.to_str().expect("UTF-8 temp path"),
3209 )
3210 .expect("migrate database");
3211 let has_operations = client
3212 .conn
3213 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3214 .expect("prepare table info")
3215 .query_map([], |row| row.get::<_, String>(1))
3216 .expect("query table info")
3217 .filter_map(Result::ok)
3218 .any(|column| column == "operations_json");
3219 assert!(has_operations);
3220 drop(client);
3221 std::fs::remove_file(path).expect("remove temp database");
3222 }
3223
3224 #[test]
3225 fn durable_conflict_outcome_and_resolution_survive_reopen() {
3226 let path = std::env::temp_dir().join(format!(
3227 "syncular-durable-outcome-{}.db",
3228 uuid::Uuid::new_v4()
3229 ));
3230 let schema = json!({
3231 "version": 1,
3232 "tables": [{
3233 "name": "tasks",
3234 "primaryKey": "id",
3235 "columns": [
3236 { "name": "id", "type": "string", "nullable": false },
3237 { "name": "project_id", "type": "string", "nullable": false }
3238 ],
3239 "scopes": [{ "pattern": "project:{project_id}" }]
3240 }]
3241 });
3242 let conflict = ConflictRecord {
3243 client_commit_id: "losing-commit".to_owned(),
3244 op_index: 0,
3245 table: "tasks".to_owned(),
3246 row_id: "t1".to_owned(),
3247 code: "sync.version_conflict".to_owned(),
3248 message: "stale base version".to_owned(),
3249 server_version: 2,
3250 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3251 conflict_columns: vec!["project_id".to_owned()],
3252 operation: Some(CommitOperation {
3253 table: "tasks".to_owned(),
3254 row_id: "t1".to_owned(),
3255 op: "upsert".to_owned(),
3256 base_version: Some(1),
3257 values: None,
3258 }),
3259 };
3260 let failed_operations = vec![
3261 OutboxOp {
3262 upsert: true,
3263 table: "tasks".to_owned(),
3264 row_id: "t1".to_owned(),
3265 base_version: Some(1),
3266 values: None,
3267 },
3268 OutboxOp {
3269 upsert: true,
3270 table: "tasks".to_owned(),
3271 row_id: "status-event-1".to_owned(),
3272 base_version: Some(0),
3273 values: None,
3274 },
3275 ];
3276
3277 {
3278 let mut first = SyncClient::open_path(
3279 "durable-native".to_owned(),
3280 &schema,
3281 ClientLimits::default(),
3282 path.to_str().expect("UTF-8 temp path"),
3283 )
3284 .expect("first open");
3285 first
3286 .begin_observation("test_outcome")
3287 .expect("begin outcome");
3288 first
3289 .persist_commit_outcome(
3290 "losing-commit",
3291 CommitOutcomeStatus::Conflict,
3292 &[CommitOperationOutcome::Conflict {
3293 conflict: conflict.clone(),
3294 }],
3295 Some(&failed_operations),
3296 )
3297 .expect("persist outcome");
3298 first.conflicts.push(conflict);
3299 first
3300 .finish_observation(
3301 "test_outcome",
3302 ChangeAccumulator {
3303 conflicts: true,
3304 outcomes: true,
3305 ..ChangeAccumulator::default()
3306 },
3307 )
3308 .expect("commit outcome");
3309 }
3310
3311 {
3312 let mut reopened = SyncClient::open_path(
3313 "durable-native".to_owned(),
3314 &schema,
3315 ClientLimits::default(),
3316 path.to_str().expect("UTF-8 temp path"),
3317 )
3318 .expect("reopen");
3319 assert_eq!(reopened.conflicts().len(), 1);
3320 assert_eq!(
3321 reopened.conflicts()[0].conflict_columns,
3322 vec!["project_id".to_owned()],
3323 "conflictColumns survives the journal round trip"
3324 );
3325 let outcome = reopened
3326 .commit_outcome("losing-commit")
3327 .expect("read outcome")
3328 .expect("outcome");
3329 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3330 let operations = outcome.operations.expect("aggregate envelope");
3331 assert_eq!(operations.len(), 2);
3332 assert_eq!(operations[1].row_id, "status-event-1");
3333 let resolved = reopened
3334 .resolve_commit_outcome(ResolveCommitOutcomeInput {
3335 client_commit_id: "losing-commit".to_owned(),
3336 resolution: CommitOutcomeResolution::ResolvedKeepServer,
3337 replacement_client_commit_id: None,
3338 })
3339 .expect("resolve");
3340 assert_eq!(
3341 resolved.resolution,
3342 CommitOutcomeResolution::ResolvedKeepServer
3343 );
3344 assert!(reopened.conflicts().is_empty());
3345 }
3346
3347 let reopened = SyncClient::open_path(
3348 "durable-native".to_owned(),
3349 &schema,
3350 ClientLimits::default(),
3351 path.to_str().expect("UTF-8 temp path"),
3352 )
3353 .expect("second reopen");
3354 assert!(reopened.conflicts().is_empty());
3355 assert_eq!(
3356 reopened
3357 .commit_outcome("losing-commit")
3358 .expect("read outcome")
3359 .expect("outcome")
3360 .resolution,
3361 CommitOutcomeResolution::ResolvedKeepServer
3362 );
3363 drop(reopened);
3364 std::fs::remove_file(path).expect("remove temp database");
3365 }
3366
3367 #[test]
3368 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3369 let path =
3370 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3371 let schema = json!({
3372 "version": 1,
3373 "tables": [{
3374 "name": "tasks",
3375 "primaryKey": "id",
3376 "columns": [
3377 { "name": "id", "type": "string", "nullable": false },
3378 { "name": "project_id", "type": "string", "nullable": false }
3379 ],
3380 "scopes": [{ "pattern": "project:{project_id}" }]
3381 }]
3382 });
3383 let mut client = SyncClient::open_path_with_identity(
3384 Some("sidecar-client".to_owned()),
3385 &schema,
3386 ClientLimits::default(),
3387 path.to_str().expect("UTF-8 temp path"),
3388 )
3389 .expect("open owner");
3390 let base = WindowBase {
3391 table: "tasks".to_owned(),
3392 variable: "project_id".to_owned(),
3393 fixed_scopes: Vec::new(),
3394 params: None,
3395 };
3396 client
3397 .set_window(&base, &["one".to_owned()])
3398 .expect("set window");
3399 client
3400 .mutate(vec![Mutation::Upsert {
3401 table: "tasks".to_owned(),
3402 values: Map::from_iter([
3403 ("id".to_owned(), Value::from("t1")),
3404 ("project_id".to_owned(), Value::from("one")),
3405 ]),
3406 base_version: None,
3407 }])
3408 .expect("local mutate");
3409
3410 let coverage = [WindowCoverage {
3411 base,
3412 units: vec!["one".to_owned(), "missing".to_owned()],
3413 }];
3414 let owner = client
3415 .query_snapshot(
3416 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3417 &[],
3418 &coverage,
3419 None,
3420 )
3421 .expect("owner snapshot");
3422 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3423 let sidecar = reader
3424 .query_snapshot(
3425 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3426 &[],
3427 &coverage,
3428 )
3429 .expect("sidecar snapshot");
3430
3431 assert_eq!(sidecar.revision, owner.revision);
3432 assert_eq!(sidecar.rows, owner.rows);
3433 assert_eq!(
3434 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3435 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3436 );
3437 assert_eq!(sidecar.revision, "2");
3438 assert_eq!(sidecar.rows[0]["id"], "t1");
3439 assert_eq!(sidecar.rows[0]["server_version"], -1);
3440 assert!(!sidecar.coverage.complete);
3441 assert_eq!(sidecar.coverage.pending.len(), 1);
3442 assert_eq!(sidecar.coverage.missing.len(), 1);
3443
3444 drop(reader);
3445 drop(client);
3446 std::fs::remove_file(path).expect("remove temp database");
3447 }
3448
3449 #[test]
3450 fn owned_query_failures_classify_order_bound_and_clear() {
3451 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3452 rusqlite::ffi::Error::new(266),
3453 Some("private SQLite prose".to_owned()),
3454 ));
3455 assert_eq!(io.code, Some("client.storage_io"));
3456 assert_eq!(io.sqlite_code, Some(266));
3457 assert_eq!(io.message, "local SQLite storage I/O failed");
3458 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3459 rusqlite::ffi::Error::new(26),
3460 None,
3461 ));
3462 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
3463
3464 let mut client = client();
3465 let generic = QueryReadFailure::from("private query prose".to_owned());
3466 client.record_query_read(
3467 QueryOwner {
3468 id: "first",
3469 tables: &["tasks", "tasks"],
3470 },
3471 Some(&generic),
3472 );
3473 client.record_query_read(
3474 QueryOwner {
3475 id: "second",
3476 tables: &["tasks"],
3477 },
3478 Some(&generic),
3479 );
3480 client.record_query_read(
3481 QueryOwner {
3482 id: "first",
3483 tables: &["docs"],
3484 },
3485 Some(&io),
3486 );
3487 let failures = &client
3488 .diagnostics_snapshot(&Default::default())
3489 .expect("diagnostics")
3490 .query_failures;
3491 assert_eq!(
3492 failures
3493 .iter()
3494 .map(|entry| entry.id.as_str())
3495 .collect::<Vec<_>>(),
3496 vec!["second", "first"]
3497 );
3498 assert_eq!(failures[1].tables, vec!["docs"]);
3499 assert_eq!(failures[1].code, "client.storage_io");
3500
3501 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
3502 client.record_query_read(
3503 QueryOwner {
3504 id: &format!("query-{index}"),
3505 tables: &["tasks"],
3506 },
3507 Some(&generic),
3508 );
3509 }
3510 let failures = &client
3511 .diagnostics_snapshot(&Default::default())
3512 .expect("bounded diagnostics")
3513 .query_failures;
3514 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
3515 assert_eq!(failures.last().expect("last failure").id, "query-256");
3516
3517 client
3518 .query_snapshot(
3519 "SELECT id FROM tasks",
3520 &[],
3521 &[],
3522 Some(QueryOwner {
3523 id: "query-256",
3524 tables: &["tasks"],
3525 }),
3526 )
3527 .expect("successful owned read");
3528 assert!(client
3529 .diagnostics_snapshot(&Default::default())
3530 .expect("cleared diagnostics")
3531 .query_failures
3532 .iter()
3533 .all(|entry| entry.id != "query-256"));
3534 }
3535
3536 #[test]
3537 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3538 let mut client = client();
3539 let image = Connection::open_in_memory().unwrap();
3540 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3541 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3542 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3543 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3544 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3545 let table = client.schema.table("tasks").unwrap().clone();
3546 let scopes = vec![("project_id".into(), vec!["p1".into()])];
3547 assert!(client
3548 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3549 .is_err());
3550 assert!(client.conn.is_autocommit());
3551 assert_eq!(
3552 client
3553 .query("SELECT count(*) AS n FROM tasks", &[])
3554 .unwrap()[0]["n"],
3555 1024
3556 );
3557 assert_eq!(client.drain_change_batches().len(), 1);
3558 image
3559 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3560 .unwrap();
3561 assert!(matches!(
3562 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3563 Ok(2050)
3564 ));
3565 assert_eq!(
3566 client
3567 .query("SELECT count(*) AS n FROM tasks", &[])
3568 .unwrap()[0]["n"],
3569 2050
3570 );
3571 assert_eq!(client.drain_change_batches().len(), 3);
3572 }
3573
3574 #[test]
3575 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3576 let mut client = client();
3577 let base = WindowBase {
3578 table: "tasks".into(),
3579 variable: "project_id".into(),
3580 fixed_scopes: Vec::new(),
3581 params: None,
3582 };
3583 client
3584 .set_window(&base, &["p1".into(), "p2".into()])
3585 .unwrap();
3586 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3587 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3588 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3589 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3590 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3591 client.drain_change_batches();
3592 assert!(client.set_window(&base, &["p2".into()]).is_err());
3593 assert!(client.conn.is_autocommit());
3594 assert_eq!(
3595 client
3596 .query("SELECT count(*) AS n FROM tasks", &[])
3597 .unwrap()[0]["n"],
3598 1027
3599 );
3600 assert_eq!(client.window_state(&base).units, vec!["p2"]);
3601 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
3602 assert_eq!(client.drain_change_batches().len(), 1);
3603 client
3604 .conn
3605 .execute_batch("DROP TRIGGER interrupt_evict")
3606 .unwrap();
3607 client.drain_pending_evictions().unwrap();
3608 assert!(client.load_pending_evictions().unwrap().is_empty());
3609 assert_eq!(
3610 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3611 "held"
3612 );
3613 assert_eq!(client.drain_change_batches().len(), 2);
3614 }
3615
3616 #[test]
3617 fn eviction_marker_failures_roll_back_window_changes() {
3618 let mut client = client();
3619 let base = WindowBase {
3620 table: "tasks".into(),
3621 variable: "project_id".into(),
3622 fixed_scopes: Vec::new(),
3623 params: None,
3624 };
3625 client.set_window(&base, &["p1".into()]).unwrap();
3626 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3627 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3628 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3629 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3630 let revision = client.local_revision();
3631 assert!(client.set_window(&base, &[]).is_err());
3632 assert!(client.conn.is_autocommit());
3633 assert_eq!(client.local_revision(), revision);
3634 assert_eq!(
3635 client
3636 .query("SELECT count(*) AS n FROM tasks", &[])
3637 .unwrap()[0]["n"],
3638 2050
3639 );
3640 assert_eq!(client.window_state(&base).units, vec!["p1"]);
3641 client.conn.execute_batch("DROP TRIGGER fail_marker;
3642 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3643 assert!(client.set_window(&base, &[]).is_err());
3644 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
3645 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3646 assert!(client.drain_pending_evictions().is_err());
3647 assert!(client.conn.is_autocommit());
3648 assert_eq!(
3649 client
3650 .query("SELECT count(*) AS n FROM tasks", &[])
3651 .unwrap()[0]["n"],
3652 2
3653 );
3654 assert!(client.set_window(&base, &["p1".into()]).is_err());
3655 assert!(client.conn.is_autocommit());
3656 assert!(client.window_state(&base).units.is_empty());
3657 assert!(client.subs.is_empty());
3658 assert_eq!(
3659 client
3660 .query(
3661 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
3662 &[]
3663 )
3664 .unwrap()[0]["n"],
3665 1
3666 );
3667 client
3668 .conn
3669 .execute_batch("DROP TRIGGER fail_marker")
3670 .unwrap();
3671 client.set_window(&base, &["p1".into()]).unwrap();
3672 assert_eq!(client.window_state(&base).units, vec!["p1"]);
3673 }
3674
3675 #[test]
3676 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
3677 let mut client = client();
3678 client
3679 .conn
3680 .execute(
3681 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
3682 [],
3683 )
3684 .unwrap();
3685 assert_eq!(
3686 client.drain_pending_evictions().unwrap_err(),
3687 "sync.local_corrupt: invalid pending eviction scopes"
3688 );
3689 assert!(client.conn.is_autocommit());
3690 assert!(client.enqueue_startup_sync_if_needed().is_err());
3691 }
3692
3693 #[test]
3694 fn image_chunks_reject_null_primary_keys() {
3695 let mut client = client();
3696 let image = Connection::open_in_memory().unwrap();
3697 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3698 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
3699 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
3700 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3701 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
3702 let table = client.schema.table("tasks").unwrap().clone();
3703 assert!(client
3704 .apply_sqlite_image(
3705 &image,
3706 &table,
3707 true,
3708 &[("project_id".into(), vec!["p1".into()])],
3709 1025,
3710 7,
3711 "digest"
3712 )
3713 .is_err());
3714 assert!(client.conn.is_autocommit());
3715 assert_eq!(
3716 client
3717 .query("SELECT count(*) AS n FROM tasks", &[])
3718 .unwrap()[0]["n"],
3719 0
3720 );
3721 }
3722
3723 #[test]
3724 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
3725 for images in [false, true] {
3726 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
3727 "name":name,"primaryKey":"id","columns":[
3728 {"name":"id","type":"string","nullable":false},
3729 {"name":"project_id","type":"string","nullable":false},
3730 {"name":"title","type":"string","nullable":false}],
3731 "scopes":[{"pattern":"project:{project_id}"}],
3732 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
3733 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
3734 })))});
3735 let mut actual =
3736 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
3737 let mut reference =
3738 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
3739 let scopes = vec![("project_id".into(), vec!["p1".into()])];
3740 for client in [&mut actual, &mut reference] {
3741 client
3742 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
3743 .unwrap();
3744 client.subs[0].effective = Some(scopes.clone());
3745 for (table, id, title) in [
3746 ("tasks", "a", "original"),
3747 ("tasks", "b", "occupied"),
3748 ("tasks", "c", "deleted"),
3749 ("notes", "n", "untouched"),
3750 ] {
3751 client
3752 .write_base_row(
3753 table,
3754 &vec![
3755 Some(ColumnValue::String(id.into())),
3756 Some(ColumnValue::String("p1".into())),
3757 Some(ColumnValue::String(title.into())),
3758 ],
3759 1,
3760 )
3761 .unwrap();
3762 }
3763 client.rebuild_overlay_if_dirty();
3764 client
3765 .mutate(vec![
3766 Mutation::Upsert {
3767 table: "tasks".into(),
3768 values: Map::from_iter([
3769 ("id".into(), Value::from("a")),
3770 ("project_id".into(), Value::from("p1")),
3771 ("title".into(), Value::from("available")),
3772 ]),
3773 base_version: None,
3774 },
3775 Mutation::Delete {
3776 table: "tasks".into(),
3777 row_id: "c".into(),
3778 base_version: None,
3779 },
3780 Mutation::Upsert {
3781 table: "tasks".into(),
3782 values: Map::from_iter([
3783 ("id".into(), Value::from("local")),
3784 ("project_id".into(), Value::from("p1")),
3785 ("title".into(), Value::from("local value")),
3786 ]),
3787 base_version: None,
3788 },
3789 Mutation::Upsert {
3790 table: "notes".into(),
3791 values: Map::from_iter([
3792 ("id".into(), Value::from("n")),
3793 ("project_id".into(), Value::from("p1")),
3794 ("title".into(), Value::from("pending note")),
3795 ]),
3796 base_version: None,
3797 },
3798 ])
3799 .unwrap();
3800 }
3801 let pending = actual.pending_commit_ids();
3802 actual.overlay_rebuild_count.set(0);
3803 for (id, title) in [
3804 ("b", "available"),
3805 ("b", "occupied"),
3806 ("a", "server original"),
3807 ("d", "local value"),
3808 ("d", "released"),
3809 ("c", "redelivered"),
3810 ] {
3811 let table = actual.schema.table("tasks").unwrap().clone();
3812 let values = vec![
3813 Some(ColumnValue::String(id.into())),
3814 Some(ColumnValue::String("p1".into())),
3815 Some(ColumnValue::String(title.into())),
3816 ];
3817 if images {
3818 let image = Connection::open_in_memory().unwrap();
3819 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3820 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3821 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
3822 image
3823 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
3824 .unwrap();
3825 assert!(matches!(
3826 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
3827 Ok(1)
3828 ));
3829 } else {
3830 let segment = RowsSegment {
3831 table: "tasks".into(),
3832 schema_version: 1,
3833 columns: table.wire_columns.clone(),
3834 blocks: vec![vec![ssp2::segment::SegmentRow {
3835 server_version: 2,
3836 values: values.clone(),
3837 }]],
3838 };
3839 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
3840 }
3841 reference.write_base_row("tasks", &values, 2).unwrap();
3842 reference.rebuild_overlay();
3843 for table in ["tasks", "notes"] {
3844 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")] {
3845 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
3846 }
3847 }
3848 assert_eq!(actual.pending_commit_ids(), pending);
3849 }
3850 assert_eq!(
3851 actual.overlay_rebuild_count.get(),
3852 0,
3853 "each chunk must leave unrelated tables alone"
3854 );
3855 let before = actual
3856 .query("SELECT * FROM tasks ORDER BY id", &[])
3857 .unwrap();
3858 let fts_before = actual
3859 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
3860 .unwrap();
3861 let revision = actual.local_revision();
3862 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
3863 let table = actual.schema.table("tasks").unwrap().clone();
3864 let segment = RowsSegment {
3865 table: "tasks".into(),
3866 schema_version: 1,
3867 columns: table.wire_columns.clone(),
3868 blocks: vec![vec![ssp2::segment::SegmentRow {
3869 server_version: 3,
3870 values: vec![
3871 Some(ColumnValue::String("b".into())),
3872 Some(ColumnValue::String("p1".into())),
3873 Some(ColumnValue::String("fresh".into())),
3874 ],
3875 }]],
3876 };
3877 assert!(actual.apply_segment(0, &segment, false).is_err());
3878 assert!(actual.conn.is_autocommit());
3879 assert_eq!(
3880 actual
3881 .query("SELECT * FROM tasks ORDER BY id", &[])
3882 .unwrap(),
3883 before
3884 );
3885 assert_eq!(
3886 actual
3887 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
3888 .unwrap(),
3889 fts_before
3890 );
3891 assert_eq!(actual.local_revision(), revision);
3892 assert_eq!(actual.pending_commit_ids(), pending);
3893 }
3894 }
3895
3896 #[test]
3897 fn import_reconciliation_preserves_typed_pending_primary_keys() {
3898 for (kind, a, b) in [
3899 ("string", json!("a"), json!("b")),
3900 ("integer", json!(1), json!(2)),
3901 ("boolean", json!(true), json!(false)),
3902 ("json", json!("1"), json!("2")),
3903 ] {
3904 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}]}]});
3905 for images in [false, true] {
3906 let mut client =
3907 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
3908 let table = client.schema.table("tasks").unwrap().clone();
3909 client
3910 .subscribe("tasks".into(), "tasks".into(), vec![], None)
3911 .unwrap();
3912 for (id, title) in [(&a, "original"), (&b, "occupied")] {
3913 client
3914 .write_base_row(
3915 "tasks",
3916 &vec![
3917 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
3918 Some(ColumnValue::String(title.into())),
3919 ],
3920 1,
3921 )
3922 .unwrap();
3923 }
3924 client.rebuild_overlay_if_dirty();
3925 client
3926 .mutate(vec![Mutation::Upsert {
3927 table: "tasks".into(),
3928 values: Map::from_iter([
3929 ("id".into(), a.clone()),
3930 ("title".into(), json!("available")),
3931 ]),
3932 base_version: None,
3933 }])
3934 .unwrap();
3935 client.overlay_rebuild_count.set(0);
3936 for (title, expected) in [("available", "original"), ("occupied", "available")] {
3937 let values = vec![
3938 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
3939 Some(ColumnValue::String(title.into())),
3940 ];
3941 if images {
3942 let image = Connection::open_in_memory().unwrap();
3943 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
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',1);").unwrap();
3946 image
3947 .execute(
3948 "INSERT INTO tasks VALUES (?1,?2,2)",
3949 rusqlite::params![RowParam::Cell(&values[0]), title],
3950 )
3951 .unwrap();
3952 assert!(
3953 matches!(
3954 client.apply_sqlite_image(
3955 &image,
3956 &table,
3957 false,
3958 &[],
3959 1,
3960 7,
3961 "digest"
3962 ),
3963 Ok(1)
3964 ),
3965 "{kind}, images={images}"
3966 );
3967 } else {
3968 let segment = RowsSegment {
3969 table: "tasks".into(),
3970 schema_version: 1,
3971 columns: table.wire_columns.clone(),
3972 blocks: vec![vec![ssp2::segment::SegmentRow {
3973 server_version: 2,
3974 values,
3975 }]],
3976 };
3977 assert!(
3978 matches!(client.apply_segment(0, &segment, false), Ok(1)),
3979 "{kind}"
3980 );
3981 }
3982 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
3983 let actual: String = client
3984 .conn
3985 .query_row(
3986 "SELECT title FROM tasks WHERE id=?1",
3987 [RowParam::Cell(&key)],
3988 |row| row.get(0),
3989 )
3990 .unwrap();
3991 assert_eq!(actual, expected, "{kind}, images={images}");
3992 }
3993 assert_eq!(client.overlay_rebuild_count.get(), 0);
3994 }
3995 }
3996 }
3997
3998 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
3999 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4000 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4001 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4002 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4003 })))});
4004 let mut client =
4005 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4006 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();
4007 client.rebuild_overlay();
4008 client
4009 .mutate(vec![Mutation::Upsert {
4010 table: "tasks".into(),
4011 values: Map::from_iter([
4012 ("id".into(), json!("offline")),
4013 ("project_id".into(), json!("p1")),
4014 ("title".into(), json!("pending edit")),
4015 ]),
4016 base_version: None,
4017 }])
4018 .unwrap();
4019 let image = Connection::open_in_memory().unwrap();
4020 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4021 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4022 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();
4023 image
4024 .execute(
4025 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4026 [rows],
4027 )
4028 .unwrap();
4029 client.overlay_rebuild_count.set(0);
4030 let table = client.schema.table("tasks").unwrap().clone();
4031 (client, image, table)
4032 }
4033
4034 #[test]
4035 fn large_unique_import_copies_no_unrelated_tables() {
4036 let (mut client, image, table) = unique_import_fixture(10240);
4037 let pending = client.pending_commit_ids();
4038 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4039 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4040 assert!(matches!(
4041 client.apply_sqlite_image(
4042 &image,
4043 &table,
4044 true,
4045 &[("project_id".into(), vec!["p1".into()])],
4046 10240,
4047 7,
4048 "digest"
4049 ),
4050 Ok(10240)
4051 ));
4052 assert_eq!(client.overlay_rebuild_count.get(), 0);
4053 assert_eq!(client.pending_commit_ids(), pending);
4054 assert_eq!(
4055 client
4056 .query("SELECT count(*) AS n FROM tasks", &[])
4057 .unwrap()[0]["n"],
4058 10241
4059 );
4060 assert_eq!(
4061 client
4062 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4063 .unwrap()[0]["n"],
4064 10241
4065 );
4066 assert_eq!(
4067 client
4068 .query("SELECT count(*) AS n FROM notes_fts", &[])
4069 .unwrap()[0]["n"],
4070 8192
4071 );
4072 }
4073
4074 #[test]
4075 #[ignore = "manual scaling benchmark; see bench/README.md"]
4076 fn benchmark_unique_image_import() {
4077 let mut samples = Vec::new();
4078 for rows in [10240, 51200, 102400] {
4079 for trial in 0..4 {
4080 let (mut client, image, table) = unique_import_fixture(rows);
4081 let start = std::time::Instant::now();
4082 assert!(
4083 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)
4084 );
4085 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4086 assert_eq!(
4087 client
4088 .query("SELECT count(*) AS n FROM tasks", &[])
4089 .unwrap()[0]["n"],
4090 rows + 1
4091 );
4092 assert_eq!(
4093 client
4094 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4095 .unwrap()[0]["n"],
4096 rows + 1
4097 );
4098 assert_eq!(
4099 client
4100 .query("SELECT count(*) AS n FROM notes_fts", &[])
4101 .unwrap()[0]["n"],
4102 8192
4103 );
4104 assert_eq!(client.pending_commit_ids().len(), 1);
4105 if trial > 0 {
4106 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4107 }
4108 }
4109 }
4110 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});
4111 let output = serde_json::to_string_pretty(&artifact).unwrap();
4112 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4113 use std::io::Write;
4114 let mut file = std::fs::OpenOptions::new()
4115 .write(true)
4116 .create_new(true)
4117 .open(path)
4118 .unwrap();
4119 file.write_all(output.as_bytes()).unwrap();
4120 } else {
4121 println!("{output}");
4122 }
4123 }
4124
4125 #[test]
4126 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4127 let schema = json!({
4128 "version": 1,
4129 "tables": [{
4130 "name": "catalogue_codes",
4131 "primaryKey": "id",
4132 "columns": [
4133 { "name": "id", "type": "string", "nullable": false },
4134 { "name": "release_id", "type": "string", "nullable": false },
4135 { "name": "code", "type": "string", "nullable": false },
4136 { "name": "title", "type": "string", "nullable": false }
4137 ],
4138 "scopes": [{ "pattern": "release:{release_id}" }],
4139 "ftsIndexes": [{
4140 "name": "catalogue_codes_fts",
4141 "columns": ["code", "title"],
4142 "tokenize": "unicode61 remove_diacritics 2"
4143 }]
4144 }]
4145 });
4146 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4147 .expect("FTS5 client");
4148 let insert_trigger: String = client
4149 .conn
4150 .query_row(
4151 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4152 [],
4153 |row| row.get(0),
4154 )
4155 .expect("insert trigger");
4156 let replace_guard: String = client
4157 .conn
4158 .query_row(
4159 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4160 [],
4161 |row| row.get(0),
4162 )
4163 .expect("replace guard");
4164 assert!(!insert_trigger.contains("DELETE FROM"));
4165 assert!(replace_guard.contains("BEFORE INSERT"));
4166 assert!(replace_guard.contains("WHEN EXISTS"));
4167 let search = |client: &SyncClient, query: &str| {
4168 client
4169 .query(
4170 "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",
4171 &[Value::from(query)],
4172 )
4173 .expect("FTS query")
4174 };
4175
4176 client
4177 .mutate(vec![Mutation::Upsert {
4178 table: "catalogue_codes".to_owned(),
4179 values: Map::from_iter([
4180 ("id".to_owned(), Value::from("c1")),
4181 ("release_id".to_owned(), Value::from("r1")),
4182 ("code".to_owned(), Value::from("A01")),
4183 ("title".to_owned(), Value::from("Cholera")),
4184 ]),
4185 base_version: None,
4186 }])
4187 .expect("insert code");
4188 assert_eq!(search(&client, "cholera").len(), 1);
4189 let prior = client
4190 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4191 .unwrap();
4192 client
4193 .conn
4194 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4195 .unwrap();
4196 client.create_synced_tables().unwrap();
4197 assert_eq!(
4198 client
4199 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4200 .unwrap(),
4201 prior
4202 );
4203 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();
4204 let rendered = serde_json::to_string(&plan).unwrap();
4205 assert!(rendered.contains("COVERING INDEX"));
4206 assert!(rendered.contains("INDEX 0:="));
4207 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4208 assert_eq!(search(&client, "cholera").len(), 1);
4209
4210 client
4211 .mutate(vec![Mutation::Upsert {
4212 table: "catalogue_codes".to_owned(),
4213 values: Map::from_iter([
4214 ("id".to_owned(), Value::from("c1")),
4215 ("release_id".to_owned(), Value::from("r1")),
4216 ("code".to_owned(), Value::from("A01")),
4217 ("title".to_owned(), Value::from("Enteric infection")),
4218 ]),
4219 base_version: None,
4220 }])
4221 .expect("update code");
4222 assert!(search(&client, "cholera").is_empty());
4223 assert_eq!(search(&client, "enteric").len(), 1);
4224
4225 client
4226 .mutate(vec![Mutation::Delete {
4227 table: "catalogue_codes".to_owned(),
4228 row_id: "c1".to_owned(),
4229 base_version: None,
4230 }])
4231 .expect("delete code");
4232 assert!(search(&client, "enteric").is_empty());
4233 }
4234
4235 #[test]
4236 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4237 let schema = json!({
4238 "version": 1,
4239 "tables": [{
4240 "name": "patient_notes",
4241 "primaryKey": "id",
4242 "columns": [
4243 { "name": "id", "type": "string", "nullable": false },
4244 { "name": "practice_id", "type": "string", "nullable": false },
4245 { "name": "encryption_key_id", "type": "string", "nullable": false },
4246 { "name": "title", "type": "string", "nullable": false }
4247 ],
4248 "scopes": [{ "pattern": "practice:{practice_id}" }],
4249 "ftsIndexes": [{
4250 "name": "patient_notes_fts",
4251 "columns": ["title"],
4252 "tokenize": "unicode61 remove_diacritics 2"
4253 }]
4254 }]
4255 });
4256 let mut client = SyncClient::new(
4257 "local-purge-test".to_owned(),
4258 &schema,
4259 ClientLimits::default(),
4260 )
4261 .expect("local purge client");
4262 client
4263 .conn
4264 .execute(
4265 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4266 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4267 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4268 [],
4269 )
4270 .expect("seed base rows");
4271 client.overlay_dirty.set(true);
4272 client.rebuild_overlay_if_dirty();
4273
4274 let note = |id: &str, key_id: &str, title: &str| {
4275 Map::from_iter([
4276 ("id".to_owned(), Value::from(id)),
4277 ("practice_id".to_owned(), Value::from("practice-1")),
4278 ("encryption_key_id".to_owned(), Value::from(key_id)),
4279 ("title".to_owned(), Value::from(title)),
4280 ])
4281 };
4282 let doomed = client
4283 .mutate(vec![
4284 Mutation::Upsert {
4285 table: "patient_notes".to_owned(),
4286 values: note("target", "key-revoked", "Target changed"),
4287 base_version: None,
4288 },
4289 Mutation::Upsert {
4290 table: "patient_notes".to_owned(),
4291 values: note("unrelated", "key-held", "Unrelated changed"),
4292 base_version: None,
4293 },
4294 ])
4295 .expect("doomed commit");
4296 let kept = client
4297 .mutate(vec![Mutation::Upsert {
4298 table: "patient_notes".to_owned(),
4299 values: note("kept", "key-held", "Kept optimistic"),
4300 base_version: None,
4301 }])
4302 .expect("kept commit");
4303 client.drain_change_batches();
4304
4305 let input = LocalDataPurgeInput {
4306 purge_id: "purge-001".to_owned(),
4307 targets: vec![LocalDataPurgeTarget {
4308 table: "patient_notes".to_owned(),
4309 selectors: BTreeMap::from([(
4310 "encryption_key_id".to_owned(),
4311 vec!["key-revoked".to_owned()],
4312 )]),
4313 }],
4314 };
4315 assert_eq!(
4316 client.purge_local_data(&input).expect("apply purge"),
4317 LocalDataPurgeResult {
4318 already_applied: false,
4319 purged_rows: 1,
4320 dropped_commits: 1,
4321 }
4322 );
4323 let rows = client
4324 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
4325 .expect("visible rows");
4326 assert_eq!(rows.len(), 2);
4327 assert_eq!(rows[0]["id"], "kept");
4328 assert_eq!(rows[1]["id"], "unrelated");
4329 assert_eq!(rows[1]["title"], "Unrelated original");
4330 let fts = client
4331 .query(
4332 "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'",
4333 &[],
4334 )
4335 .expect("fts query");
4336 assert!(fts.is_empty());
4337 assert_eq!(client.outbox.len(), 1);
4338 assert_eq!(client.outbox[0].client_commit_id, kept);
4339 let outcome = client
4340 .commit_outcome(&doomed)
4341 .expect("read doomed outcome")
4342 .expect("doomed outcome");
4343 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
4344 match &outcome.results[0] {
4345 CommitOperationOutcome::Error { rejection } => {
4346 assert_eq!(rejection.code, "client.local_data_purged");
4347 assert_eq!(rejection.client_commit_id, doomed);
4348 }
4349 other => panic!("expected local purge rejection, got {other:?}"),
4350 }
4351 assert_eq!(client.drain_change_batches().len(), 1);
4352 assert_eq!(
4353 client.purge_local_data(&input).expect("retry purge"),
4354 LocalDataPurgeResult {
4355 already_applied: true,
4356 purged_rows: 0,
4357 dropped_commits: 0,
4358 }
4359 );
4360 let conflicting = LocalDataPurgeInput {
4361 purge_id: input.purge_id.clone(),
4362 targets: vec![LocalDataPurgeTarget {
4363 table: "patient_notes".to_owned(),
4364 selectors: BTreeMap::from([(
4365 "encryption_key_id".to_owned(),
4366 vec!["key-held".to_owned()],
4367 )]),
4368 }],
4369 };
4370 assert!(client
4371 .purge_local_data(&conflicting)
4372 .expect_err("id collision must fail")
4373 .contains("already used with a different plan"));
4374 }
4375}
4376
4377impl SubState {
4378 fn name(self) -> &'static str {
4379 match self {
4380 SubState::Active => "active",
4381 SubState::Revoked => "revoked",
4382 SubState::Failed => "failed",
4383 }
4384 }
4385
4386 fn parse(value: &str) -> Self {
4387 match value {
4388 "revoked" => Self::Revoked,
4389 "failed" => Self::Failed,
4390 _ => Self::Active,
4391 }
4392 }
4393}
4394
4395#[derive(Debug, Clone)]
4396struct Subscription {
4397 id: String,
4398 table: String,
4399 requested: Vec<(String, Vec<String>)>,
4400 params: Option<String>,
4401 cursor: i64,
4402 bootstrap_state: Option<String>,
4404 state: SubState,
4405 reason_code: Option<String>,
4406 effective: Option<Vec<(String, Vec<String>)>>,
4409 synced_once: bool,
4410}
4411
4412#[derive(Debug, Clone)]
4413struct OutboxOp {
4414 upsert: bool,
4415 table: String,
4416 row_id: String,
4417 base_version: Option<i64>,
4418 values: Option<Map<String, Value>>,
4422}
4423
4424impl From<&OutboxOp> for CommitOperation {
4425 fn from(operation: &OutboxOp) -> Self {
4426 Self {
4427 table: operation.table.clone(),
4428 row_id: operation.row_id.clone(),
4429 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4430 base_version: operation.base_version,
4431 values: operation.values.clone(),
4432 }
4433 }
4434}
4435
4436#[derive(Debug, Clone)]
4437struct OutboxCommit {
4438 client_commit_id: String,
4439 ops: Vec<OutboxOp>,
4440}
4441
4442fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
4446 commit
4447 .ops
4448 .iter()
4449 .filter(|operation| operation.upsert)
4450 .map(|operation| {
4451 (
4452 operation.table.as_str(),
4453 operation
4454 .values
4455 .as_ref()
4456 .map(|values| values.keys().map(String::as_str).collect())
4457 .unwrap_or_default(),
4458 )
4459 })
4460 .collect()
4461}
4462
4463#[derive(Debug, Clone)]
4464struct CompiledLocalDataPurgeTarget {
4465 table: String,
4466 selectors: Vec<(String, Vec<String>)>,
4467}
4468
4469struct StoredCommitOutcomeRow {
4470 sequence: i64,
4471 client_commit_id: String,
4472 status: String,
4473 recorded_at_ms: i64,
4474 results_json: String,
4475 operations_json: Option<String>,
4476 resolution: String,
4477 resolved_at_ms: Option<i64>,
4478 replacement_client_commit_id: Option<String>,
4479}
4480
4481enum SectionError {
4484 FailClosed,
4485 Abort(String, String),
4486}
4487
4488struct RequestMeta {
4489 pushed_ids: Vec<String>,
4490 fresh: Vec<(String, bool)>,
4493 accept: u8,
4494 deferred_commits: usize,
4498}
4499
4500#[derive(Default)]
4501struct ChangeAccumulator {
4502 tables: BTreeMap<String, Option<BTreeSet<String>>>,
4504 windows: BTreeMap<(String, String), BTreeSet<String>>,
4505 status: bool,
4506 conflicts: bool,
4507 rejections: bool,
4508 outcomes: bool,
4509}
4510
4511impl ChangeAccumulator {
4512 fn table(&mut self, table: &str) {
4513 self.tables.insert(table.to_owned(), None);
4514 }
4515
4516 fn scope(&mut self, table: &str, key: String) {
4517 match self.tables.get_mut(table) {
4518 Some(None) => {}
4519 Some(Some(keys)) => {
4520 keys.insert(key);
4521 }
4522 None => {
4523 self.tables
4524 .insert(table.to_owned(), Some(BTreeSet::from([key])));
4525 }
4526 }
4527 }
4528
4529 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4530 self.windows
4531 .entry((base_key.to_owned(), table.to_owned()))
4532 .or_default()
4533 .insert(unit.to_owned());
4534 }
4535
4536 fn touched(&self) -> bool {
4537 !self.tables.is_empty()
4538 || !self.windows.is_empty()
4539 || self.status
4540 || self.conflicts
4541 || self.rejections
4542 || self.outcomes
4543 }
4544}
4545
4546const PUSH_OPS_PER_REQUEST: usize = 500;
4552const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4553const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4554const MAX_LOCAL_PURGE_VALUES: usize = 128;
4555const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4556pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4557
4558pub struct SyncClient {
4559 #[cfg(feature = "bench-internals")]
4560 benchmark_phases: Recorder,
4561 conn: Connection,
4562 schema: ClientSchema,
4563 client_id: String,
4564 limits: ClientLimits,
4565 subs: Vec<Subscription>,
4566 outbox: Vec<OutboxCommit>,
4567 conflicts: Vec<ConflictRecord>,
4568 rejections: Vec<RejectionRecord>,
4569 schema_floor: Option<SchemaFloor>,
4570 lease_state: Option<LeaseState>,
4572 stopped: bool,
4574 upgrading: bool,
4576 sync_needed: bool,
4578 realtime_connected: bool,
4579 presence: HashMap<String, HashMap<String, PresencePeer>>,
4581 now_ms: Option<i64>,
4584 encryption: crate::values::EncryptionConfig,
4588 security_preflight: bool,
4591 previous_version: Option<PreviousVersionContextConfig>,
4595 insert_sql: RefCell<HashMap<String, String>>,
4601 overlay_dirty: Cell<bool>,
4605 #[cfg(test)]
4608 overlay_rebuild_count: Cell<usize>,
4609 #[cfg(test)]
4612 outcome_prune_count: Cell<usize>,
4613 progress: ProgressObserver,
4615 change_queue: VecDeque<ClientChangeBatch>,
4616 sync_intent_queue: VecDeque<SyncIntent>,
4617 retry_delay_ms: u64,
4619 last_round: Option<DiagnosticLastRound>,
4620 last_change: Option<DiagnosticLastChange>,
4621 query_failures: Vec<DiagnosticQueryFailure>,
4623}
4624
4625pub(crate) fn quote_ident(name: &str) -> String {
4626 format!("\"{}\"", name.replace('"', "\"\""))
4627}
4628pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
4632 conn.query_row(
4633 "SELECT value FROM _syncular_meta WHERE key = ?1",
4634 rusqlite::params![key],
4635 |row| row.get::<_, String>(0),
4636 )
4637 .ok()
4638}
4639
4640pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
4641 let _ = conn.execute(
4642 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
4643 rusqlite::params![key, value],
4644 );
4645}
4646
4647pub(crate) fn meta_delete(conn: &Connection, key: &str) {
4648 let _ = conn.execute(
4649 "DELETE FROM _syncular_meta WHERE key = ?1",
4650 rusqlite::params![key],
4651 );
4652}
4653
4654fn row_id_predicate(table: &TableSchema) -> String {
4656 let key = quote_ident(&table.primary_key);
4657 let text_match = format!("CAST({key} AS TEXT) = ?1");
4658 match table.columns[table.pk_index].ty {
4659 ColumnType::String | ColumnType::Json => {
4660 format!("{key} = ?1 AND {text_match}")
4661 }
4662 ColumnType::Integer | ColumnType::Boolean => {
4666 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
4667 }
4668 _ => text_match,
4671 }
4672}
4673
4674fn is_local_operation_code_like(value: &str) -> bool {
4675 let bytes = value.as_bytes();
4676 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
4677 && bytes
4678 .iter()
4679 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
4680}
4681
4682fn base_table(name: &str) -> String {
4683 quote_ident(&format!("_syncular_base_{name}"))
4684}
4685
4686type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
4691
4692fn window_base_key(base: &WindowBase) -> String {
4693 format!(
4694 "{}\0{}\0{}",
4695 base.table,
4696 base.variable,
4697 canonical_scope_json(&base.fixed_scopes)
4698 )
4699}
4700
4701fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
4703 let mut scopes = base.fixed_scopes.clone();
4704 scopes.retain(|(k, _)| k != &base.variable);
4705 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
4706 scopes
4707}
4708
4709fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
4713 let canonical = canonical_scope_json(&unit_scopes(base, unit));
4714 let digest = Sha256::digest(canonical.as_bytes());
4715 let hex = bytes_to_hex(&digest);
4716 format!("w:{}:{}", base.table, &hex[..16])
4717}
4718
4719fn visible_table(name: &str) -> String {
4720 quote_ident(name)
4721}
4722
4723const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
4724
4725fn is_synced_table_name(name: &str) -> bool {
4728 if name.starts_with("sqlite_") {
4729 return false;
4730 }
4731 if name.starts_with("_syncular_base_") {
4732 return true; }
4734 !name.starts_with("_syncular_")
4736}
4737
4738fn blob_id_for(bytes: &[u8]) -> String {
4740 let digest = Sha256::digest(bytes);
4741 format!("sha256:{}", bytes_to_hex(&digest))
4742}
4743
4744enum RowParam<'a> {
4748 Cell(&'a Option<ColumnValue>),
4749 Version(i64),
4750}
4751
4752impl rusqlite::ToSql for RowParam<'_> {
4753 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
4754 Ok(match self {
4755 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
4756 RowParam::Cell(cell) => match cell {
4757 None => ToSqlOutput::Owned(SqlValue::Null),
4758 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
4759 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
4760 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
4761 Some(ColumnValue::Boolean(b)) => {
4762 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
4763 }
4764 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
4765 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
4766 }
4767 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
4769 ToSqlOutput::Borrowed(ValueRef::Blob(b))
4770 }
4771 },
4772 })
4773 }
4774}
4775
4776fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
4778 match param.to_sql().map_err(|error| error.to_string())? {
4779 ToSqlOutput::Owned(value) => Ok(value),
4780 ToSqlOutput::Borrowed(value) => {
4781 SqlValue::try_from(value).map_err(|error| error.to_string())
4782 }
4783 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
4784 }
4785}
4786
4787fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
4795 use ssp2::segment::ColumnType;
4796 let mismatch = || {
4797 Err(format!(
4798 "image column {:?} holds a value of the wrong type",
4799 column.name
4800 ))
4801 };
4802 match value {
4803 ValueRef::Null => {
4804 if !column.nullable {
4805 return Err(format!(
4806 "image column {:?} is NULL but not nullable",
4807 column.name
4808 ));
4809 }
4810 Ok(ToSqlOutput::Owned(SqlValue::Null))
4811 }
4812 ValueRef::Integer(i) => match column.ty {
4813 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
4814 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
4815 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
4816 _ => mismatch(),
4817 },
4818 ValueRef::Real(_) => match column.ty {
4819 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
4820 _ => mismatch(),
4821 },
4822 ValueRef::Text(t) => {
4823 std::str::from_utf8(t)
4824 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
4825 match column.ty {
4826 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
4827 Ok(ToSqlOutput::Borrowed(value))
4828 }
4829 _ => mismatch(),
4830 }
4831 }
4832 ValueRef::Blob(_) => match column.ty {
4833 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
4835 _ => mismatch(),
4836 },
4837 }
4838}
4839
4840pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
4841 use rusqlite::types::ValueRef;
4842 match value {
4843 ValueRef::Null => Value::Null,
4844 ValueRef::Integer(i) => match column.ty {
4845 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
4846 ssp2::segment::ColumnType::Float => {
4847 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
4848 }
4849 _ => Value::from(i),
4850 },
4851 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
4852 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
4853 ValueRef::Blob(b) => {
4854 let mut map = Map::new();
4855 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
4856 Value::Object(map)
4857 }
4858 }
4859}
4860
4861fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
4865 Ok(match value {
4866 Value::Null => SqlValue::Null,
4867 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
4868 Value::Number(n) => {
4869 if let Some(i) = n.as_i64() {
4870 SqlValue::Integer(i)
4871 } else if let Some(f) = n.as_f64() {
4872 SqlValue::Real(f)
4873 } else {
4874 return Err(format!("query param number {n} is out of range"));
4875 }
4876 }
4877 Value::String(s) => SqlValue::Text(s.clone()),
4878 Value::Object(_) => {
4879 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
4880 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
4881 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
4882 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
4883 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
4884 })?)
4885 } else {
4886 return Err(
4887 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
4888 .to_owned(),
4889 );
4890 }
4891 }
4892 Value::Array(_) => return Err("query array params are not supported".to_owned()),
4893 })
4894}
4895
4896fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
4901 use rusqlite::types::ValueRef;
4902 match value {
4903 ValueRef::Null => Value::Null,
4904 ValueRef::Integer(i) => {
4905 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
4909 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
4910 Value::from(i)
4911 } else {
4912 let mut map = Map::new();
4913 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
4914 Value::Object(map)
4915 }
4916 }
4917 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
4918 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
4919 ValueRef::Blob(b) => {
4920 let mut map = Map::new();
4921 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
4922 Value::Object(map)
4923 }
4924 }
4925}
4926
4927fn query_connection(
4929 conn: &Connection,
4930 sql: &str,
4931 params: &[QueryValue],
4932) -> Result<Vec<QueryRow>, QueryReadFailure> {
4933 crate::query_guard::assert_read_only_query(sql)?;
4934 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
4935 let bound: Vec<SqlValue> = params
4936 .iter()
4937 .map(json_param_to_sql)
4938 .collect::<Result<_, _>>()?;
4939 let mut stmt = conn.prepare(&lowered_sql)?;
4940 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
4941 let bound_refs: Vec<&dyn rusqlite::ToSql> =
4942 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
4943 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
4944 let mut out = Vec::new();
4945 while let Some(row) = sql_rows.next()? {
4946 let mut record = Map::new();
4947 for (i, name) in column_names.iter().enumerate() {
4948 let value = row.get_ref(i)?;
4949 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
4950 }
4951 out.push(record);
4952 }
4953 Ok(out)
4954}
4955
4956fn persisted_window_state(
4957 conn: &Connection,
4958 base: &WindowBase,
4959) -> Result<WindowState, QueryReadFailure> {
4960 let mut stmt = conn.prepare(
4961 "SELECT windows.unit, subscriptions.state_json
4962 FROM _syncular_windows AS windows
4963 JOIN _syncular_subscriptions AS subscriptions
4964 ON subscriptions.id = windows.sub_id
4965 WHERE windows.base = ?1
4966 ORDER BY windows.unit ASC",
4967 )?;
4968 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
4969 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
4970 })?;
4971 let mut units = Vec::new();
4972 let mut pending = Vec::new();
4973 for row in rows {
4974 let (unit, raw) = row?;
4975 let state: Value = serde_json::from_str(&raw)
4976 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
4977 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
4978 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
4979 || state
4980 .get("bootstrapState")
4981 .is_some_and(|value| !value.is_null());
4982 if is_pending {
4983 pending.push(unit.clone());
4984 }
4985 units.push(unit);
4986 }
4987 Ok(WindowState { units, pending })
4988}
4989
4990fn snapshot_connection(
4991 conn: &Connection,
4992 sql: &str,
4993 params: &[Value],
4994 coverage: &[WindowCoverage],
4995) -> Result<QuerySnapshot, QueryReadFailure> {
4996 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
4997 let result = (|| {
4998 let revision = conn
4999 .query_row(
5000 "SELECT value FROM _syncular_meta WHERE key = ?1",
5001 rusqlite::params![LOCAL_REVISION_KEY],
5002 |row| row.get::<_, String>(0),
5003 )
5004 .ok()
5005 .and_then(|value| value.parse::<u64>().ok())
5006 .unwrap_or(0);
5007 let rows = query_connection(conn, sql, params)?;
5008 let mut pending = Vec::new();
5009 let mut missing = Vec::new();
5010 for requested in coverage {
5011 let base_key = window_base_key(&requested.base);
5012 let state = persisted_window_state(conn, &requested.base)?;
5013 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5014 let reference = WindowUnitRef {
5015 base_key: base_key.clone(),
5016 unit: unit.clone(),
5017 };
5018 if !state.units.iter().any(|held| held == &unit) {
5019 missing.push(reference);
5020 } else if state.pending.iter().any(|held| held == &unit) {
5021 pending.push(reference);
5022 }
5023 }
5024 }
5025 Ok(QuerySnapshot {
5026 revision: revision.to_string(),
5027 rows,
5028 coverage: CoverageSnapshot {
5029 complete: pending.is_empty() && missing.is_empty(),
5030 pending,
5031 missing,
5032 },
5033 })
5034 })();
5035 match result {
5036 Ok(snapshot) => {
5037 conn.execute_batch("RELEASE syncular_snapshot_read")?;
5038 Ok(snapshot)
5039 }
5040 Err(error) => {
5041 let _ = conn.execute_batch(
5042 "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
5043 );
5044 Err(error)
5045 }
5046 }
5047}
5048
5049pub struct FileQuerySnapshotReader {
5054 path: String,
5055 conn: Option<Connection>,
5056}
5057
5058impl FileQuerySnapshotReader {
5059 #[must_use]
5060 pub fn new(path: impl Into<String>) -> Self {
5061 Self {
5062 path: path.into(),
5063 conn: None,
5064 }
5065 }
5066
5067 fn connection(&mut self) -> Result<&Connection, String> {
5068 if self.conn.is_none() {
5069 let conn = Connection::open_with_flags(
5070 &self.path,
5071 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5072 )
5073 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5074 conn.busy_timeout(std::time::Duration::from_millis(250))
5075 .map_err(|error| error.to_string())?;
5076 self.conn = Some(conn);
5077 }
5078 self.conn
5079 .as_ref()
5080 .ok_or_else(|| "read sidecar connection missing".to_owned())
5081 }
5082
5083 pub fn query_snapshot(
5086 &mut self,
5087 sql: &str,
5088 params: &[Value],
5089 coverage: &[WindowCoverage],
5090 ) -> Result<QuerySnapshot, QueryReadFailure> {
5091 snapshot_connection(self.connection()?, sql, params, coverage)
5092 }
5093}
5094
5095impl SyncClient {
5096 pub fn new_with_identity(
5097 client_id: Option<String>,
5098 schema_json: &Value,
5099 limits: ClientLimits,
5100 ) -> Result<Self, String> {
5101 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5102 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5103 Self::with_connection(resolved, schema_json, limits, conn)
5104 }
5105
5106 pub fn new(
5107 client_id: String,
5108 schema_json: &Value,
5109 limits: ClientLimits,
5110 ) -> Result<Self, String> {
5111 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5112 Self::with_connection(client_id, schema_json, limits, conn)
5113 }
5114
5115 pub fn open_path(
5121 client_id: String,
5122 schema_json: &Value,
5123 limits: ClientLimits,
5124 path: &str,
5125 ) -> Result<Self, String> {
5126 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5127 Self::with_connection(client_id, schema_json, limits, conn)
5128 }
5129
5130 pub fn open_path_with_identity(
5131 client_id: Option<String>,
5132 schema_json: &Value,
5133 limits: ClientLimits,
5134 path: &str,
5135 ) -> Result<Self, String> {
5136 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5137 conn.busy_timeout(std::time::Duration::from_millis(250))
5143 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5144 conn.pragma_update(None, "journal_mode", "WAL")
5145 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5146 let persisted = conn
5147 .query_row(
5148 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5149 [],
5150 |row| row.get::<_, String>(0),
5151 )
5152 .ok();
5153 let resolved = persisted
5154 .clone()
5155 .or(client_id.clone())
5156 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5157 if let (Some(existing), Some(requested)) = (persisted, client_id) {
5158 if existing != requested {
5159 return Err(format!(
5160 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5161 ));
5162 }
5163 }
5164 Self::with_connection(resolved, schema_json, limits, conn)
5165 }
5166
5167 #[must_use]
5168 pub fn client_id(&self) -> &str {
5169 &self.client_id
5170 }
5171
5172 pub fn with_connection(
5179 client_id: String,
5180 schema_json: &Value,
5181 limits: ClientLimits,
5182 conn: Connection,
5183 ) -> Result<Self, String> {
5184 if limits.outcome_retention_max_entries == Some(0) {
5185 return Err(
5186 "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5187 );
5188 }
5189 let schema = parse_schema_json(schema_json)?;
5190 let previous_version = match limits.previous_version_context {
5193 Some(config) => {
5194 config.validate()?;
5195 Some(config)
5196 }
5197 None => None,
5198 };
5199 let mut client = SyncClient {
5200 #[cfg(feature = "bench-internals")]
5201 benchmark_phases: Recorder::default(),
5202 conn,
5203 schema,
5204 client_id,
5205 limits,
5206 subs: Vec::new(),
5207 outbox: Vec::new(),
5208 conflicts: Vec::new(),
5209 rejections: Vec::new(),
5210 schema_floor: None,
5211 lease_state: None,
5212 stopped: false,
5213 upgrading: false,
5214 sync_needed: false,
5215 realtime_connected: false,
5216 presence: HashMap::new(),
5217 now_ms: None,
5218 encryption: crate::values::EncryptionConfig::default(),
5219 security_preflight: false,
5220 previous_version,
5221 insert_sql: RefCell::new(HashMap::new()),
5222 overlay_dirty: Cell::new(false),
5223 #[cfg(test)]
5224 overlay_rebuild_count: Cell::new(0),
5225 #[cfg(test)]
5226 outcome_prune_count: Cell::new(0),
5227 progress: ProgressObserver::default(),
5228 change_queue: VecDeque::new(),
5229 sync_intent_queue: VecDeque::new(),
5230 retry_delay_ms: 250,
5231 last_round: None,
5232 last_change: None,
5233 query_failures: Vec::new(),
5234 };
5235 client
5239 .conn
5240 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5241 client.create_bookkeeping_tables()?;
5245 match client.get_meta(CLIENT_ID_KEY) {
5246 Some(existing) if existing != client.client_id => {
5247 return Err(format!(
5248 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5249 client.client_id
5250 ));
5251 }
5252 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5253 _ => {}
5254 }
5255 client.restore_persisted_state()?;
5256 let marker = client
5257 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5258 .and_then(|value| value.parse::<i32>().ok());
5259 match marker {
5260 None => {
5261 client.create_synced_tables()?;
5262 client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5263 set_local_schema_descriptor(&client.conn, &client.schema);
5265 }
5266 Some(version) if version == client.schema.version => {
5267 client.create_synced_tables()?;
5268 set_local_schema_descriptor(&client.conn, &client.schema);
5272 }
5273 Some(_) => client.run_schema_reset()?,
5274 }
5275 client.reconcile_previous_version_at_boot()?;
5281 client.clear_satisfied_persisted_schema_floor();
5282 client.prune_unknown_subscriptions()?;
5283 if marker == Some(client.schema.version) && !client.outbox.is_empty() {
5284 client.overlay_dirty.set(true);
5287 client.rebuild_overlay();
5288 }
5289 client.enqueue_startup_sync_if_needed()?;
5295 Ok(client)
5296 }
5297
5298 pub fn set_now_ms(&mut self, now_ms: i64) {
5301 self.now_ms = Some(now_ms);
5302 }
5303
5304 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
5308 self.encryption = encryption;
5309 }
5310
5311 #[must_use]
5312 pub fn security_lifecycle(&self) -> &'static str {
5313 if self.security_preflight {
5314 "preflight"
5315 } else {
5316 "active"
5317 }
5318 }
5319
5320 #[must_use]
5321 pub fn security_preflight(&self) -> bool {
5322 self.security_preflight
5323 }
5324
5325 pub fn begin_security_preflight(&mut self) {
5332 self.seal_security_on_teardown();
5333 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
5336 }
5337
5338 pub fn seal_security_on_teardown(&mut self) {
5348 self.security_preflight = true;
5349 self.encryption = crate::values::EncryptionConfig::default();
5350 self.sync_intent_queue.clear();
5351 }
5352
5353 pub fn activate_security(
5355 &mut self,
5356 encryption: crate::values::EncryptionConfig,
5357 ) -> Result<(), String> {
5358 if !self.security_preflight {
5359 return Err(
5360 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
5361 );
5362 }
5363 self.enqueue_startup_sync_if_needed()?;
5364 self.encryption = encryption;
5365 self.security_preflight = false;
5366 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
5367 Ok(())
5368 }
5369
5370 fn clock_now_ms(&self) -> i64 {
5371 self.now_ms.unwrap_or_else(|| {
5372 std::time::SystemTime::now()
5373 .duration_since(std::time::UNIX_EPOCH)
5374 .map(|d| d.as_millis() as i64)
5375 .unwrap_or(0)
5376 })
5377 }
5378
5379 fn create_bookkeeping_tables(&self) -> Result<(), String> {
5380 self.conn
5382 .execute_batch(
5383 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
5384 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5385 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
5386 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
5387 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5388 client_commit_id TEXT NOT NULL UNIQUE,
5389 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
5390 recorded_at_ms INTEGER NOT NULL,
5391 results_json TEXT NOT NULL,
5392 operations_json TEXT,
5393 resolution TEXT NOT NULL DEFAULT 'active'
5394 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
5395 resolved_at_ms INTEGER,
5396 replacement_client_commit_id TEXT);
5397 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
5398 ON _syncular_commit_outcomes(resolution, seq);
5399 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
5400 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
5401 CREATE TABLE IF NOT EXISTS _syncular_meta (
5402 key TEXT PRIMARY KEY, value TEXT NOT NULL);
5403 CREATE TABLE IF NOT EXISTS _syncular_windows (
5404 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
5405 PRIMARY KEY (base, unit));
5406 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
5407 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
5408 effective_scopes TEXT NOT NULL);
5409 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
5410 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
5411 PRIMARY KEY(commit_id, blob_id));
5412 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
5413 ON _syncular_blob_commit_refs(blob_id);",
5414 )
5415 .map_err(|e| e.to_string())?;
5416 let _ = self
5419 .conn
5420 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
5421 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
5422 self.set_meta(LOCAL_REVISION_KEY, "0");
5423 }
5424 if self.schema_has_blobs() {
5427 let exists = self
5428 .conn
5429 .query_row(
5430 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5431 [],
5432 |row| row.get::<_, bool>(0),
5433 )
5434 .map_err(|e| e.to_string())?;
5435 if exists {
5436 let mut statement = self
5437 .conn
5438 .prepare("PRAGMA table_info(_syncular_blobs)")
5439 .map_err(|e| e.to_string())?;
5440 let mut columns = statement
5441 .query_map([], |row| row.get::<_, String>(1))
5442 .map_err(|e| e.to_string())?
5443 .collect::<Result<Vec<_>, _>>()
5444 .map_err(|e| e.to_string())?;
5445 columns.sort();
5446 let mut expected = vec![
5447 "blob_id",
5448 "byte_length",
5449 "bytes",
5450 "created_at_ms",
5451 "media_type",
5452 ];
5453 expected.sort_unstable();
5454 if columns != expected {
5455 return Err(
5456 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5457 );
5458 }
5459 } else {
5460 self.conn
5461 .execute_batch(
5462 "CREATE TABLE _syncular_blobs(
5463 blob_id TEXT PRIMARY KEY,
5464 bytes BLOB NOT NULL,
5465 byte_length INTEGER NOT NULL,
5466 media_type TEXT,
5467 created_at_ms INTEGER NOT NULL);",
5468 )
5469 .map_err(|e| e.to_string())?;
5470 }
5471 self.conn
5472 .execute_batch(
5473 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5474 blob_id TEXT PRIMARY KEY,
5475 media_type TEXT,
5476 created_at_ms INTEGER NOT NULL);",
5477 )
5478 .map_err(|e| e.to_string())?;
5479 }
5480 Ok(())
5481 }
5482
5483 fn schema_has_blobs(&self) -> bool {
5485 self.schema
5486 .tables
5487 .iter()
5488 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5489 }
5490
5491 fn get_meta(&self, key: &str) -> Option<String> {
5494 meta_get(&self.conn, key)
5495 }
5496
5497 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5498 self.conn
5499 .query_row(
5500 "SELECT value FROM _syncular_meta WHERE key = ?1",
5501 rusqlite::params![key],
5502 |row| row.get::<_, String>(0),
5503 )
5504 .optional()
5505 .map_err(|_| {
5506 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5507 })
5508 }
5509
5510 fn set_meta(&self, key: &str, value: &str) {
5511 meta_set(&self.conn, key, value);
5512 }
5513
5514 fn delete_meta(&self, key: &str) {
5515 meta_delete(&self.conn, key);
5516 }
5517
5518 fn restore_persisted_state(&mut self) -> Result<(), String> {
5519 self.subs = {
5520 let mut stmt = self
5521 .conn
5522 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5523 .map_err(|error| error.to_string())?;
5524 let rows = stmt
5525 .query_map([], |row| {
5526 Ok((
5527 row.get::<_, String>(0)?,
5528 row.get::<_, String>(1)?,
5529 row.get::<_, String>(2)?,
5530 ))
5531 })
5532 .map_err(|error| error.to_string())?;
5533 let mut subscriptions = Vec::new();
5534 for row in rows {
5535 let (id, table, raw) = row.map_err(|error| error.to_string())?;
5536 let state: Value = serde_json::from_str(&raw)
5537 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5538 let requested = json_to_scope_map(
5539 state.get("requested").unwrap_or(&Value::Object(Map::new())),
5540 )?;
5541 let effective = state
5542 .get("effectiveScopes")
5543 .filter(|value| !value.is_null())
5544 .map(json_to_scope_map)
5545 .transpose()?;
5546 subscriptions.push(Subscription {
5547 id,
5548 table,
5549 requested,
5550 params: state
5551 .get("params")
5552 .and_then(Value::as_str)
5553 .map(str::to_owned),
5554 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
5555 bootstrap_state: state
5556 .get("bootstrapState")
5557 .and_then(Value::as_str)
5558 .map(str::to_owned),
5559 state: SubState::parse(
5560 state
5561 .get("status")
5562 .and_then(Value::as_str)
5563 .unwrap_or("active"),
5564 ),
5565 reason_code: state
5566 .get("reasonCode")
5567 .and_then(Value::as_str)
5568 .map(str::to_owned),
5569 effective,
5570 synced_once: state
5571 .get("syncedOnce")
5572 .and_then(Value::as_bool)
5573 .unwrap_or(false),
5574 });
5575 }
5576 subscriptions
5577 };
5578
5579 self.outbox = {
5580 let mut stmt = self
5581 .conn
5582 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
5583 .map_err(|error| error.to_string())?;
5584 let rows = stmt
5585 .query_map([], |row| {
5586 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5587 })
5588 .map_err(|error| error.to_string())?;
5589 let mut commits = Vec::new();
5590 for row in rows {
5591 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
5592 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
5593 format!("invalid persisted outbox {client_commit_id:?}: {error}")
5594 })?;
5595 let mut ops = Vec::with_capacity(entries.len());
5596 for entry in entries {
5597 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
5598 ops.push(OutboxOp {
5599 upsert: op == "upsert",
5600 table: entry
5601 .get("table")
5602 .and_then(Value::as_str)
5603 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
5604 .to_owned(),
5605 row_id: entry
5606 .get("rowId")
5607 .and_then(Value::as_str)
5608 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
5609 .to_owned(),
5610 base_version: entry.get("baseVersion").and_then(Value::as_i64),
5611 values: entry.get("values").and_then(Value::as_object).cloned(),
5614 });
5615 }
5616 commits.push(OutboxCommit {
5617 client_commit_id,
5618 ops,
5619 });
5620 }
5621 commits
5622 };
5623
5624 self.prune_commit_outcomes()?;
5625 let active = self.commit_outcomes(CommitOutcomeQuery {
5626 active_only: true,
5627 ..CommitOutcomeQuery::default()
5628 })?;
5629 self.conflicts = active
5630 .iter()
5631 .flat_map(|outcome| outcome.results.iter())
5632 .filter_map(|result| match result {
5633 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
5634 _ => None,
5635 })
5636 .collect();
5637 self.rejections = active
5638 .iter()
5639 .flat_map(|outcome| outcome.results.iter())
5640 .filter_map(|result| match result {
5641 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
5642 _ => None,
5643 })
5644 .collect();
5645
5646 self.lease_state = self
5647 .get_meta(LEASE_STATE_KEY)
5648 .map(|raw| serde_json::from_str(&raw))
5649 .transpose()
5650 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
5651 self.schema_floor = self
5652 .get_meta(SCHEMA_FLOOR_KEY)
5653 .map(|raw| serde_json::from_str(&raw))
5654 .transpose()
5655 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
5656 self.stopped = self.schema_floor.is_some();
5657 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
5660 self.security_preflight = true;
5661 }
5662 Ok(())
5663 }
5664
5665 fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
5668 let valid_tables: BTreeSet<String> = self
5669 .schema
5670 .tables
5671 .iter()
5672 .map(|table| table.name.clone())
5673 .collect();
5674 let stale_ids: Vec<String> = self
5675 .subs
5676 .iter()
5677 .filter(|sub| !valid_tables.contains(&sub.table))
5678 .map(|sub| sub.id.clone())
5679 .collect();
5680 for id in &stale_ids {
5681 self.conn
5682 .execute(
5683 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
5684 rusqlite::params![id],
5685 )
5686 .map_err(|error| error.to_string())?;
5687 self.conn
5688 .execute(
5689 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
5690 rusqlite::params![id],
5691 )
5692 .map_err(|error| error.to_string())?;
5693 self.conn
5694 .execute(
5695 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
5696 rusqlite::params![id],
5697 )
5698 .map_err(|error| error.to_string())?;
5699 }
5700 self.subs.retain(|sub| valid_tables.contains(&sub.table));
5701 Ok(())
5702 }
5703
5704 #[must_use]
5705 pub fn local_revision(&self) -> u64 {
5706 self.get_meta(LOCAL_REVISION_KEY)
5707 .and_then(|value| value.parse().ok())
5708 .unwrap_or(0)
5709 }
5710
5711 #[must_use]
5712 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
5713 SyncStatusSnapshot {
5714 current_schema_version: self.schema.version,
5715 outbox: self.outbox.len(),
5716 upgrading: self.upgrading,
5717 lease_state: self.lease_state.clone(),
5718 schema_floor: self.schema_floor.clone(),
5719 sync_needed: self.sync_needed,
5720 previous_version_context: self.previous_version_status(),
5721 }
5722 }
5723
5724 fn previous_version_status(&self) -> PreviousVersionStatus {
5727 let Some(replica_path) = self.previous_version_replica_path() else {
5728 return PreviousVersionStatus {
5729 present: false,
5730 created_at_ms: None,
5731 };
5732 };
5733 match crate::previous_version::read_previous_version_record(&replica_path) {
5734 Ok(Some(record)) => PreviousVersionStatus {
5735 present: true,
5736 created_at_ms: Some(record.created_at_ms),
5737 },
5738 _ => PreviousVersionStatus {
5739 present: std::path::Path::new(
5740 &crate::previous_version::previous_version_container_path(&replica_path),
5741 )
5742 .exists(),
5743 created_at_ms: None,
5744 },
5745 }
5746 }
5747
5748 pub fn previous_version_snapshot(
5754 &self,
5755 spec: &PreviousVersionReadSpec,
5756 ) -> Result<PreviousVersionSnapshot, String> {
5757 let now_ms = self.clock_now_ms();
5758 crate::previous_version::previous_version_snapshot(
5759 &self.conn,
5760 self.previous_version_replica_path().as_deref(),
5761 self.previous_version.as_ref(),
5762 PreviousVersionLifecycle {
5763 lease_inactive: self.previous_version_lease_inactive(now_ms),
5764 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
5765 coverage_complete: self.previous_version_coverage_complete(),
5766 },
5767 now_ms,
5768 self.schema.version,
5769 spec,
5770 )
5771 }
5772
5773 #[must_use]
5775 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
5776 crate::previous_version::stored_previous_version_audit(&self.conn)
5777 }
5778
5779 pub fn previous_version_discard(
5783 &self,
5784 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
5785 self.drop_previous_version()
5786 }
5787
5788 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
5792 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
5793 present: false,
5794 discarded: false,
5795 };
5796 let Some(replica_path) = self.previous_version_replica_path() else {
5797 return absent;
5798 };
5799 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
5800 .unwrap_or(absent)
5801 }
5802
5803 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
5805 let Some(lease) = &self.lease_state else {
5806 return false;
5807 };
5808 if lease.error_code.is_some() {
5809 return true;
5810 }
5811 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
5812 }
5813
5814 fn previous_version_coverage_complete(&self) -> bool {
5818 let active = self
5819 .subs
5820 .iter()
5821 .filter(|sub| sub.state == SubState::Active)
5822 .collect::<Vec<_>>();
5823 if active.is_empty() {
5824 return false;
5825 }
5826 active
5827 .iter()
5828 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
5829 }
5830
5831 fn previous_version_lifetime_check(&self) {
5835 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
5836 return;
5837 };
5838 let now_ms = self.clock_now_ms();
5839 if self.previous_version_lease_inactive(now_ms) {
5840 let _ = self.drop_previous_version();
5841 return;
5842 }
5843 let Some(replica_path) = self.previous_version_replica_path() else {
5844 return;
5845 };
5846 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
5847 else {
5848 return;
5849 };
5850 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
5851 let _ = self.drop_previous_version();
5852 return;
5853 }
5854 if now_ms - record.created_at_ms > config.max_age_ms {
5855 let _ = self.drop_previous_version();
5856 return;
5857 }
5858 if self.previous_version_coverage_complete() {
5859 let _ = self.drop_previous_version();
5860 }
5861 }
5862
5863 pub fn diagnostics_snapshot(
5864 &self,
5865 request: &ClientDiagnosticsRequest,
5866 ) -> Result<ClientDiagnosticsSnapshot, String> {
5867 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
5868 return Err(format!(
5869 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
5870 ));
5871 }
5872 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
5873 for sub in &self.subs {
5874 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
5875 let complete =
5876 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
5877 let state = match sub.state {
5878 SubState::Revoked => "revoked",
5879 SubState::Failed => "failed",
5880 SubState::Active if reset => "reset",
5881 SubState::Active if complete => "complete",
5882 SubState::Active => "bootstrapping",
5883 };
5884 subscriptions.insert(
5885 sub.id.clone(),
5886 DiagnosticSubscription {
5887 id: sub.id.clone(),
5888 table: sub.table.clone(),
5889 state: state.to_owned(),
5890 complete,
5891 cursor: Some(sub.cursor),
5892 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
5893 },
5894 );
5895 }
5896 for expected in &request.expected_subscriptions {
5897 if expected.id.is_empty() || expected.table.is_empty() {
5898 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
5899 }
5900 if subscriptions
5901 .get(&expected.id)
5902 .is_some_and(|registered| registered.table != expected.table)
5903 {
5904 subscriptions.insert(
5905 expected.id.clone(),
5906 DiagnosticSubscription {
5907 id: expected.id.clone(),
5908 table: expected.table.clone(),
5909 state: "failed".to_owned(),
5910 complete: false,
5911 cursor: None,
5912 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
5913 },
5914 );
5915 } else {
5916 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
5917 DiagnosticSubscription {
5918 id: expected.id.clone(),
5919 table: expected.table.clone(),
5920 state: "unregistered".to_owned(),
5921 complete: false,
5922 cursor: None,
5923 reason_code: None,
5924 }
5925 });
5926 }
5927 }
5928 let mut ordered_subscriptions = Vec::new();
5929 let mut included = BTreeSet::new();
5930 for expected in &request.expected_subscriptions {
5931 if included.insert(expected.id.clone()) {
5932 if let Some(subscription) = subscriptions.get(&expected.id) {
5933 ordered_subscriptions.push(subscription.clone());
5934 }
5935 }
5936 }
5937 for (id, subscription) in &subscriptions {
5938 if included.insert(id.clone()) {
5939 ordered_subscriptions.push(subscription.clone());
5940 }
5941 }
5942 let subscriptions_truncated =
5943 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
5944 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
5945 let captured_at_ms = self.clock_now_ms();
5946 let lease = if let Some(error_code) = self
5947 .lease_state
5948 .as_ref()
5949 .and_then(|state| state.error_code.clone())
5950 {
5951 ClientDiagnosticsLease {
5952 state: "stopped".to_owned(),
5953 expires_at_ms: self
5954 .lease_state
5955 .as_ref()
5956 .and_then(|state| state.expires_at_ms),
5957 error_code: Some(Self::diagnostic_code(&error_code)),
5958 }
5959 } else if let Some(expires_at_ms) = self
5960 .lease_state
5961 .as_ref()
5962 .and_then(|state| state.expires_at_ms)
5963 {
5964 ClientDiagnosticsLease {
5965 state: if expires_at_ms <= captured_at_ms {
5966 "expired".to_owned()
5967 } else {
5968 "active".to_owned()
5969 },
5970 expires_at_ms: Some(expires_at_ms),
5971 error_code: None,
5972 }
5973 } else {
5974 ClientDiagnosticsLease {
5975 state: "none".to_owned(),
5976 expires_at_ms: None,
5977 error_code: None,
5978 }
5979 };
5980 let connectivity = match self.last_round.as_ref() {
5981 Some(round) if round.status == "succeeded" => "online",
5982 Some(round)
5983 if round.status == "failed"
5984 && round
5985 .error_code
5986 .as_deref()
5987 .is_some_and(Self::retryable_transport_code) =>
5988 {
5989 "offline"
5990 }
5991 _ => "unknown",
5992 };
5993 Ok(ClientDiagnosticsSnapshot {
5994 version: CLIENT_DIAGNOSTICS_VERSION,
5995 captured_at_ms,
5996 host: ClientDiagnosticsHost {
5997 kind: "direct".to_owned(),
5998 role: "single".to_owned(),
5999 connectivity: connectivity.to_owned(),
6000 realtime: if self.realtime_connected {
6001 "connected".to_owned()
6002 } else {
6003 "disconnected".to_owned()
6004 },
6005 },
6006 security_lifecycle: self.security_lifecycle().to_owned(),
6007 schema: ClientDiagnosticsSchema {
6008 current_version: self.schema.version,
6009 upgrading: self.upgrading,
6010 required_version: self
6011 .schema_floor
6012 .as_ref()
6013 .and_then(|floor| floor.required_schema_version),
6014 latest_version: self
6015 .schema_floor
6016 .as_ref()
6017 .and_then(|floor| floor.latest_schema_version),
6018 },
6019 replica: ClientDiagnosticsReplica {
6020 local_revision: self.local_revision().to_string(),
6021 sync_needed: self.sync_needed,
6022 pending_outbox: self.outbox.len(),
6023 },
6024 lease,
6025 subscriptions: ordered_subscriptions,
6026 subscriptions_truncated,
6027 last_round: self.last_round.clone(),
6028 last_change: self.last_change.clone(),
6029 storage: self.diagnostics_storage(),
6030 query_failures: self.query_failures.clone(),
6031 })
6032 }
6033
6034 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
6035 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
6036 let page_count: i64 = self
6037 .conn
6038 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
6039 let page_size: i64 = self
6040 .conn
6041 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
6042 let outbox_bytes: i64 = self
6044 .conn
6045 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
6046 .query_row([], |row| row.get(0))?;
6047 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
6048 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
6049 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
6050 let blob_bytes = if self.schema_has_blobs() {
6051 self.conn
6052 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
6053 .query_row([], |row| row.get(0))?
6054 } else {
6055 0
6056 };
6057 let pressure = self
6058 .limits
6059 .blob_cache_max_bytes
6060 .is_some_and(|limit| blob_bytes > limit);
6061 Ok(ClientDiagnosticsStorage {
6062 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
6063 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
6064 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
6065 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
6066 retained_outcome_entries: Some(outcome_entries.max(0)),
6067 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
6068 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
6069 })
6070 };
6071 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
6072 status: "unreadable".to_owned(),
6073 database_bytes_approx: None,
6074 pending_outbox_bytes_approx: None,
6075 retained_outcome_bytes_approx: None,
6076 retained_outcome_entries: None,
6077 blob_cache_bytes_approx: None,
6078 pressure_reason_code: None,
6079 })
6080 }
6081
6082 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
6083 self.change_queue.drain(..).collect()
6084 }
6085
6086 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
6087 self.sync_intent_queue.drain(..).collect()
6088 }
6089
6090 fn schedule_background_retry(&mut self) {
6091 self.sync_intent_queue.push_back(SyncIntent::Background {
6092 delay_ms: self.retry_delay_ms,
6093 });
6094 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
6095 }
6096
6097 fn reset_background_retry(&mut self) {
6098 self.retry_delay_ms = 250;
6099 }
6100
6101 fn retryable_transport_code(code: &str) -> bool {
6102 code == "transport.failed"
6103 || code == "transport.unavailable"
6104 || code == "sync.transport_failed"
6105 }
6106
6107 fn diagnostic_code(code: &str) -> String {
6108 let valid = !code.is_empty()
6109 && code.len() <= 96
6110 && code.contains('.')
6111 && code
6112 .bytes()
6113 .next()
6114 .is_some_and(|byte| byte.is_ascii_lowercase())
6115 && code.bytes().all(|byte| {
6116 byte.is_ascii_lowercase()
6117 || byte.is_ascii_digit()
6118 || matches!(byte, b'.' | b'_' | b'-')
6119 });
6120 if valid {
6121 code.to_owned()
6122 } else {
6123 "client.unknown_failure".to_owned()
6124 }
6125 }
6126
6127 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
6128 if self.sync_needed != value {
6129 if self.begin_observation("syncular_status").is_ok() {
6130 self.sync_needed = value;
6131 let batch = ChangeAccumulator {
6132 status: true,
6133 ..ChangeAccumulator::default()
6134 };
6135 if self.finish_observation("syncular_status", batch).is_err() {
6136 self.rollback_observation("syncular_status");
6137 }
6138 } else {
6139 self.sync_needed = value;
6140 }
6141 }
6142 if value && interactive {
6143 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6144 }
6145 }
6146
6147 fn begin_observation(&self, name: &str) -> Result<(), String> {
6148 self.conn
6149 .execute_batch(&format!("SAVEPOINT {name}"))
6150 .map_err(|error| error.to_string())
6151 }
6152
6153 fn rollback_observation(&self, name: &str) {
6154 let _ = self
6155 .conn
6156 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
6157 }
6158
6159 fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
6160 #[cfg(feature = "bench-internals")]
6161 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
6162 if !batch.touched() {
6163 self.conn
6164 .execute_batch(&format!("RELEASE {name}"))
6165 .map_err(|error| error.to_string())?;
6166 return Ok(());
6167 }
6168 let revision = self
6169 .local_revision()
6170 .checked_add(1)
6171 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
6172 self.conn
6173 .execute(
6174 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
6175 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
6176 )
6177 .map_err(|error| error.to_string())?;
6178 let status = batch.status.then(|| self.status_snapshot());
6179 let event = ClientChangeBatch {
6180 revision: revision.to_string(),
6181 tables: batch
6182 .tables
6183 .into_iter()
6184 .map(|(table, scope_keys)| TableChange {
6185 table,
6186 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
6187 })
6188 .collect(),
6189 windows: batch
6190 .windows
6191 .into_iter()
6192 .map(|((base_key, table), units)| WindowChange {
6193 base_key,
6194 table,
6195 units: units.into_iter().collect(),
6196 })
6197 .collect(),
6198 status,
6199 conflicts_changed: batch.conflicts,
6200 rejections_changed: batch.rejections,
6201 outcomes_changed: batch.outcomes,
6202 };
6203 self.conn
6204 .execute_batch(&format!("RELEASE {name}"))
6205 .map_err(|error| error.to_string())?;
6206 let mut diagnostic_tables = event
6207 .tables
6208 .iter()
6209 .map(|entry| entry.table.clone())
6210 .collect::<BTreeSet<_>>()
6211 .into_iter()
6212 .collect::<Vec<_>>();
6213 let mut diagnostic_windows = event
6214 .windows
6215 .iter()
6216 .map(|entry| entry.table.clone())
6217 .collect::<BTreeSet<_>>()
6218 .into_iter()
6219 .collect::<Vec<_>>();
6220 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
6221 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
6222 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
6223 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
6224 self.last_change = Some(DiagnosticLastChange {
6225 revision: event.revision.clone(),
6226 recorded_at_ms: self.clock_now_ms(),
6227 tables: diagnostic_tables,
6228 windows: diagnostic_windows,
6229 domains_truncated,
6230 status_changed: event.status.is_some(),
6231 conflicts_changed: event.conflicts_changed,
6232 rejections_changed: event.rejections_changed,
6233 outcomes_changed: event.outcomes_changed,
6234 });
6235 self.change_queue.push_back(event);
6236 Ok(())
6237 }
6238
6239 fn record_scope_map(
6240 &self,
6241 batch: &mut ChangeAccumulator,
6242 table_name: &str,
6243 scopes: &[(String, Vec<String>)],
6244 ) {
6245 let Some(table) = self.schema.table(table_name) else {
6246 return;
6247 };
6248 for (variable, values) in scopes {
6249 let Some(scope) = table
6250 .scope_variables
6251 .iter()
6252 .find(|scope| &scope.variable == variable)
6253 else {
6254 continue;
6255 };
6256 for value in values {
6257 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6258 }
6259 }
6260 }
6261
6262 fn record_row_scopes(
6264 &self,
6265 batch: &mut ChangeAccumulator,
6266 table_name: &str,
6267 row_id: &str,
6268 base: bool,
6269 ) -> bool {
6270 let Some(table) = self.schema.table(table_name) else {
6271 return false;
6272 };
6273 if table.scope_variables.is_empty() {
6274 return false;
6275 }
6276 let columns = table
6277 .scope_variables
6278 .iter()
6279 .map(|scope| quote_ident(&scope.column))
6280 .collect::<Vec<_>>()
6281 .join(", ");
6282 let full_table = if base {
6283 base_table(table_name)
6284 } else {
6285 visible_table(table_name)
6286 };
6287 let sql = format!(
6288 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
6289 row_id_predicate(table)
6290 );
6291 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
6292 return false;
6293 };
6294 let values = stmt.query_row(rusqlite::params![row_id], |row| {
6295 let mut values = Vec::with_capacity(table.scope_variables.len());
6296 for index in 0..table.scope_variables.len() {
6297 values.push(row.get::<_, Option<String>>(index)?);
6298 }
6299 Ok(values)
6300 });
6301 let Ok(values) = values else {
6302 return false;
6303 };
6304 let mut recorded = false;
6305 for (scope, value) in table.scope_variables.iter().zip(values) {
6306 if let Some(value) = value {
6307 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6308 recorded = true;
6309 }
6310 }
6311 recorded
6312 }
6313
6314 fn record_commit_changes(
6315 &self,
6316 batch: &mut ChangeAccumulator,
6317 tables: &[String],
6318 changes: &[ssp2::model::Change],
6319 ) {
6320 #[cfg(feature = "bench-internals")]
6321 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
6322 for change in changes {
6323 let Some(table_name) = tables.get(change.table_index as usize) else {
6324 continue;
6325 };
6326 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
6327 if let Some(table) = self.schema.table(table_name) {
6328 for (variable, value) in &change.scopes {
6329 if let Some(scope) = table
6330 .scope_variables
6331 .iter()
6332 .find(|scope| &scope.variable == variable)
6333 {
6334 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6335 precise = true;
6336 }
6337 }
6338 }
6339 if !precise {
6340 batch.table(table_name);
6341 }
6342 }
6343 }
6344
6345 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
6346 if effective.is_empty() {
6347 return false;
6348 }
6349 let Some(table) = self.schema.table(table_name) else {
6350 return false;
6351 };
6352 let mut clauses = Vec::new();
6353 let mut params = Vec::new();
6354 for (variable, values) in effective {
6355 let Some(column) = table.scope_column(variable) else {
6356 return false;
6357 };
6358 if values.is_empty() {
6359 return false;
6360 }
6361 let placeholders = values
6362 .iter()
6363 .map(|value| {
6364 params.push(SqlValue::Text(value.clone()));
6365 "?"
6366 })
6367 .collect::<Vec<_>>()
6368 .join(", ");
6369 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
6370 }
6371 let sql = format!(
6372 "SELECT 1 FROM {} WHERE {} LIMIT 1",
6373 base_table(table_name),
6374 clauses.join(" AND ")
6375 );
6376 self.conn
6377 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
6378 .is_ok()
6379 }
6380
6381 pub fn upgrading(&self) -> bool {
6383 self.upgrading
6384 }
6385
6386 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
6392 let new_schema = parse_schema_json(schema_json)?;
6393 let marker: Option<i32> = self
6394 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6395 .and_then(|v| v.parse().ok());
6396 self.schema = new_schema;
6397 self.create_bookkeeping_tables()?;
6398 if marker != Some(self.schema.version) {
6399 self.run_schema_reset()?;
6400 }
6401 self.prune_unknown_subscriptions()?;
6402 self.enqueue_startup_sync_if_needed()?;
6406 Ok(())
6407 }
6408
6409 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
6410 let startup_work = !self.stopped
6411 && (!self.outbox.is_empty()
6412 || !self.load_pending_evictions()?.is_empty()
6413 || self.subs.iter().any(|sub| sub.state == SubState::Active));
6414 if startup_work {
6415 self.sync_needed = true;
6416 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6417 }
6418 Ok(())
6419 }
6420
6421 fn clear_satisfied_persisted_schema_floor(&mut self) {
6428 let satisfied = self
6429 .schema_floor
6430 .as_ref()
6431 .and_then(|floor| floor.required_schema_version)
6432 .is_some_and(|required| self.schema.version >= required);
6433 if !satisfied {
6434 return;
6435 }
6436 self.schema_floor = None;
6437 self.stopped = false;
6438 self.delete_meta(SCHEMA_FLOOR_KEY);
6439 }
6440
6441 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
6446 let Some(replica_path) = self.previous_version_replica_path() else {
6447 return Ok(());
6448 };
6449 let max_age_ms = self
6450 .previous_version
6451 .filter(|config| config.enabled)
6452 .map(|config| config.max_age_ms);
6453 reconcile_previous_version_at_boot(
6454 &self.conn,
6455 &replica_path,
6456 self.schema.version,
6457 max_age_ms,
6458 self.clock_now_ms(),
6459 )
6460 }
6461
6462 fn previous_version_replica_path(&self) -> Option<String> {
6466 let path = self.conn.path()?;
6467 if path.is_empty() || path.starts_with("file:") {
6468 return None;
6469 }
6470 Some(path.to_owned())
6471 }
6472
6473 fn run_schema_reset(&mut self) -> Result<(), String> {
6479 self.begin_observation("syncular_schema_reset")?;
6480 let mut batch = ChangeAccumulator::default();
6481 let result = self.run_schema_reset_observed(&mut batch, true, true);
6482 if let Err(error) = result {
6483 self.rollback_observation("syncular_schema_reset");
6484 return Err(error);
6485 }
6486 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
6487 self.rollback_observation("syncular_schema_reset");
6488 return Err(error);
6489 }
6490 Ok(())
6491 }
6492
6493 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
6494 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
6495 self.begin_observation("syncular_log_epoch_reset")?;
6496 let mut batch = ChangeAccumulator::default();
6497 let result = self.run_schema_reset_observed(&mut batch, false, false);
6498 if let Err(error) = result {
6499 self.rollback_observation("syncular_log_epoch_reset");
6500 return Err(error);
6501 }
6502 self.set_meta(LOG_EPOCH_KEY, log_epoch);
6503 self.sync_needed = true;
6504 batch.status = true;
6505 if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
6506 self.rollback_observation("syncular_log_epoch_reset");
6507 return Err(error);
6508 }
6509 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6510 Ok(resets)
6511 }
6512
6513 fn run_schema_reset_observed(
6514 &mut self,
6515 batch: &mut ChangeAccumulator,
6516 drop_incompatible: bool,
6517 capture: bool,
6518 ) -> Result<(), String> {
6519 self.upgrading = true;
6520 batch.status = true;
6521 if self.previous_version.is_some_and(|config| config.enabled) {
6527 let previous_version = self
6528 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6529 .and_then(|value| value.parse::<i32>().ok())
6530 .unwrap_or(self.schema.version);
6531 let pending = self
6532 .outbox
6533 .iter()
6534 .map(|commit| PendingCommitAudit {
6535 commit_id: &commit.client_commit_id,
6536 operations: commit_audit_operations(commit),
6537 })
6538 .collect::<Vec<_>>();
6539 crate::previous_version::write_previous_version_audit(
6540 &self.conn,
6541 &crate::previous_version::build_previous_version_audit(
6542 &self.schema,
6543 &pending,
6544 previous_version,
6545 self.schema.version,
6546 self.clock_now_ms(),
6547 ),
6548 );
6549 }
6550 if let Some(replica_path) = self.previous_version_replica_path() {
6555 sweep_previous_version_container(&self.conn, &replica_path)?;
6556 if capture {
6557 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
6558 let previous_version = self
6559 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6560 .and_then(|value| value.parse::<i32>().ok())
6561 .unwrap_or(self.schema.version);
6562 capture_previous_version_from_replica(
6563 &self.conn,
6564 &replica_path,
6565 previous_version,
6566 self.schema.version,
6567 &config,
6568 self.clock_now_ms(),
6569 )?;
6570 }
6571 }
6572 }
6573 for table in &self.schema.tables {
6574 batch.table(&table.name);
6575 }
6576 for (base_key, unit, table) in self.load_registered_window_units() {
6577 batch.window(&base_key, &table, &unit);
6578 }
6579 self.insert_sql.borrow_mut().clear();
6581 self.overlay_dirty.set(true);
6582 let virtual_tables: Vec<String> = {
6590 let mut stmt = self
6591 .conn
6592 .prepare(
6593 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
6594 )
6595 .map_err(|e| e.to_string())?;
6596 let rows = stmt
6597 .query_map([], |row| row.get::<_, String>(0))
6598 .map_err(|e| e.to_string())?;
6599 rows.filter_map(Result::ok)
6600 .filter(|name| is_synced_table_name(name))
6601 .collect()
6602 };
6603 for name in virtual_tables {
6604 self.conn
6605 .execute_batch(&format!(
6606 "DROP TABLE IF EXISTS {}",
6607 quote_ident(&format!("_syncular_fts_{name}"))
6608 ))
6609 .map_err(|e| e.to_string())?;
6610 self.conn
6611 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6612 .map_err(|e| e.to_string())?;
6613 }
6614 let existing: Vec<String> = {
6615 let mut stmt = self
6616 .conn
6617 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
6618 .map_err(|e| e.to_string())?;
6619 let rows = stmt
6620 .query_map([], |row| row.get::<_, String>(0))
6621 .map_err(|e| e.to_string())?;
6622 rows.filter_map(Result::ok)
6623 .filter(|name| is_synced_table_name(name))
6624 .collect()
6625 };
6626 for name in &existing {
6627 if let Some(table) = name.strip_prefix("_syncular_base_") {
6628 batch.table(table);
6629 } else if !name.starts_with("_syncular_") {
6630 batch.table(name);
6631 }
6632 }
6633 for name in existing {
6634 self.conn
6635 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6636 .map_err(|e| e.to_string())?;
6637 }
6638 self.create_synced_tables()?;
6640 for sub in &mut self.subs {
6642 sub.cursor = -1;
6643 sub.bootstrap_state = None;
6644 sub.effective = None;
6645 sub.state = SubState::Active;
6646 sub.reason_code = None;
6647 sub.synced_once = false;
6648 }
6649 let subs = self.subs.clone();
6650 for sub in &subs {
6651 self.persist_sub(sub)?;
6652 }
6653 self.stopped = false;
6655 self.schema_floor = None;
6656 self.delete_meta(SCHEMA_FLOOR_KEY);
6657 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
6659 set_local_schema_descriptor(&self.conn, &self.schema);
6662 if drop_incompatible && self.drop_incompatible_outbox()? {
6666 batch.rejections = true;
6667 batch.status = true;
6668 batch.outcomes = true;
6669 }
6670 self.rebuild_overlay();
6672 Ok(())
6673 }
6674
6675 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
6679 let incompatible = self
6680 .outbox
6681 .iter()
6682 .filter(|commit| {
6683 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
6684 })
6685 .cloned()
6686 .collect::<Vec<_>>();
6687 if incompatible.is_empty() {
6688 return Ok(false);
6689 }
6690 let mut rejections = Vec::new();
6691 for commit in &incompatible {
6692 let results = commit
6693 .ops
6694 .iter()
6695 .enumerate()
6696 .map(|(op_index, operation)| {
6697 let rejection = RejectionRecord {
6698 client_commit_id: commit.client_commit_id.clone(),
6699 op_index: op_index as i32,
6700 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
6701 message: "the persisted commit cannot encode under the current schema"
6702 .to_owned(),
6703 retryable: false,
6704 details: None,
6705 operation: Some(CommitOperation::from(operation)),
6706 };
6707 rejections.push(rejection.clone());
6708 CommitOperationOutcome::Error { rejection }
6709 })
6710 .collect::<Vec<_>>();
6711 self.persist_commit_outcome(
6712 &commit.client_commit_id,
6713 CommitOutcomeStatus::Rejected,
6714 &results,
6715 Some(&commit.ops),
6716 )?;
6717 self.delete_outbox_persisted(&commit.client_commit_id)?;
6718 }
6719 self.prune_commit_outcomes()?;
6720 let incompatible_ids = incompatible
6721 .iter()
6722 .map(|commit| commit.client_commit_id.as_str())
6723 .collect::<BTreeSet<_>>();
6724 self.outbox
6725 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
6726 self.rejections.extend(rejections);
6727 Ok(true)
6728 }
6729
6730 fn create_synced_tables(&self) -> Result<(), String> {
6733 for table in &self.schema.tables {
6734 for (full, index_prefix) in [
6738 (base_table(&table.name), "_syncular_base_"),
6739 (visible_table(&table.name), ""),
6740 ] {
6741 let mut cols: Vec<String> =
6742 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
6743 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
6744 let sql = format!(
6745 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
6746 cols.join(", "),
6747 quote_ident(&table.primary_key)
6748 );
6749 self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
6750 for index in &table.indexes {
6754 let unique = if index.unique { "UNIQUE " } else { "" };
6755 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
6756 let cols_sql = index
6757 .columns
6758 .iter()
6759 .map(|c| quote_ident(c))
6760 .collect::<Vec<_>>()
6761 .join(", ");
6762 let index_sql = format!(
6763 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
6764 );
6765 self.conn
6766 .execute(&index_sql, [])
6767 .map_err(|e| e.to_string())?;
6768 }
6769 }
6770 }
6771 for table in &self.schema.tables {
6774 for index in &table.fts_indexes {
6775 self.create_fts_projection(table, index)?;
6776 }
6777 }
6778 Ok(())
6779 }
6780
6781 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
6782 let count: i64 = self
6783 .conn
6784 .query_row(
6785 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
6786 rusqlite::params![index.name],
6787 |row| row.get(0),
6788 )
6789 .map_err(|e| e.to_string())?;
6790 Ok(count > 0)
6791 }
6792
6793 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
6794 for suffix in ["ai", "ad", "au"] {
6795 self.conn
6796 .execute(
6797 &format!(
6798 "DROP TRIGGER IF EXISTS {}",
6799 quote_ident(&format!("{}_{suffix}", index.name))
6800 ),
6801 [],
6802 )
6803 .map_err(|e| e.to_string())?;
6804 }
6805 Ok(())
6806 }
6807
6808 fn create_fts_triggers(
6809 &self,
6810 table: &TableSchema,
6811 index: &FtsIndexSchema,
6812 ) -> Result<(), String> {
6813 let fts = quote_ident(&index.name);
6814 let source = visible_table(&table.name);
6815 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
6816 let pk = quote_ident(&table.primary_key);
6817 let projection_columns = std::iter::once(source_id.clone())
6818 .chain(index.columns.iter().map(|column| quote_ident(column)))
6819 .collect::<Vec<_>>()
6820 .join(", ");
6821 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
6822 .chain(
6823 index
6824 .columns
6825 .iter()
6826 .map(|column| format!("new.{}", quote_ident(column))),
6827 )
6828 .collect::<Vec<_>>()
6829 .join(", ");
6830 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6831 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)");
6832 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)");
6833 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})");
6834 let bi = quote_ident(&format!("{}_bi", index.name));
6835 let ai = quote_ident(&format!("{}_ai", index.name));
6836 let ad = quote_ident(&format!("{}_ad", index.name));
6837 let au = quote_ident(&format!("{}_au", index.name));
6838 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
6839 let sql = format!(
6840 "DROP TRIGGER IF EXISTS {bi};
6841 DROP TRIGGER IF EXISTS {ai};
6842 DROP TRIGGER IF EXISTS {ad};
6843 DROP TRIGGER IF EXISTS {au};
6844 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
6845 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
6846 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
6847 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
6848 );
6849 self.conn.execute_batch(&sql).map_err(|e| e.to_string())
6850 }
6851
6852 fn rebuild_fts_projection(
6853 &self,
6854 table: &TableSchema,
6855 index: &FtsIndexSchema,
6856 ) -> Result<(), String> {
6857 let fts = quote_ident(&index.name);
6858 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
6859 let pk = quote_ident(&table.primary_key);
6860 let indexed_columns = index
6861 .columns
6862 .iter()
6863 .map(|column| quote_ident(column))
6864 .collect::<Vec<_>>();
6865 let projection_columns = std::iter::once(source_id.clone())
6866 .chain(indexed_columns.iter().cloned())
6867 .collect::<Vec<_>>()
6868 .join(", ");
6869 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6870 let sql = format!(
6871 "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};",
6872 columns = indexed_columns.join(", "),
6873 source = visible_table(&table.name),
6874 );
6875 self.conn.execute_batch(&sql).map_err(|e| e.to_string())
6876 }
6877
6878 fn create_fts_projection(
6879 &self,
6880 table: &TableSchema,
6881 index: &FtsIndexSchema,
6882 ) -> Result<(), String> {
6883 self.begin_observation("syncular_fts_schema")?;
6884 let result = (|| {
6885 let existed = self.fts_projection_exists(index)?;
6886 let tokenizer = index.tokenize.replace('\'', "''");
6887 let columns = index
6888 .columns
6889 .iter()
6890 .map(|column| quote_ident(column))
6891 .collect::<Vec<_>>()
6892 .join(", ");
6893 let sql = format!(
6894 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
6895 fts = quote_ident(&index.name),
6896 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
6897 );
6898 self.conn.execute(&sql, []).map_err(|error| {
6899 format!(
6900 "cannot create local FTS5 projection {:?}: {error}",
6901 index.name
6902 )
6903 })?;
6904 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6905 let mapped: bool = self
6906 .conn
6907 .query_row(
6908 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
6909 [format!("_syncular_fts_{}", index.name)],
6910 |row| row.get(0),
6911 )
6912 .map_err(|e| e.to_string())?;
6913 self.conn.execute_batch(&format!("CREATE TABLE IF NOT EXISTS {mapping}(id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)")).map_err(|e| e.to_string())?;
6914 if !mapped && existed {
6915 self.conn
6916 .execute_batch(&format!(
6917 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
6918 quote_ident(FTS_SOURCE_ID_COLUMN),
6919 quote_ident(&index.name)
6920 ))
6921 .map_err(|e| e.to_string())?;
6922 }
6923 self.create_fts_triggers(table, index)?;
6924 if !existed {
6925 self.rebuild_fts_projection(table, index)?;
6926 }
6927 Ok(())
6928 })();
6929 if let Err(error) = result {
6930 self.rollback_observation("syncular_fts_schema");
6931 return Err(error);
6932 }
6933 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
6934 self.rollback_observation("syncular_fts_schema");
6935 return Err(error.to_string());
6936 }
6937 Ok(())
6938 }
6939
6940 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
6943 let state = serde_json::json!({
6944 "requested": scope_map_to_json(&sub.requested),
6945 "params": sub.params,
6946 "cursor": sub.cursor,
6947 "bootstrapState": sub.bootstrap_state,
6948 "status": sub.state.name(),
6949 "reasonCode": sub.reason_code,
6950 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
6951 "syncedOnce": sub.synced_once,
6952 });
6953 self.conn.execute(
6954 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
6955 rusqlite::params![sub.id, sub.table, state.to_string()],
6956 ).map(|_| ()).map_err(|error| error.to_string())
6957 }
6958
6959 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
6960 let ops: Vec<Value> = commit
6961 .ops
6962 .iter()
6963 .map(|op| {
6964 serde_json::json!({
6965 "op": if op.upsert { "upsert" } else { "delete" },
6966 "table": op.table,
6967 "rowId": op.row_id,
6968 "baseVersion": op.base_version,
6969 "values": op.values.clone().map(Value::Object),
6970 })
6971 })
6972 .collect();
6973 self.conn
6974 .execute(
6975 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
6976 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
6977 )
6978 .map_err(|error| error.to_string())?;
6979 for operation in &commit.ops {
6980 let Some(values) = &operation.values else {
6981 continue;
6982 };
6983 let Some(table) = self.schema.table(&operation.table) else {
6984 continue;
6985 };
6986 for column in table
6987 .columns
6988 .iter()
6989 .filter(|column| column.ty == ColumnType::BlobRef)
6990 {
6991 let Some(Value::String(raw)) = values.get(&column.name) else {
6992 continue;
6993 };
6994 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
6995 continue;
6996 };
6997 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
6998 continue;
6999 };
7000 self.conn
7001 .execute(
7002 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
7003 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
7004 rusqlite::params![commit.client_commit_id, blob_id],
7005 )
7006 .map_err(|error| error.to_string())?;
7007 }
7008 }
7009 Ok(())
7010 }
7011
7012 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
7013 self.conn
7014 .execute(
7015 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
7016 rusqlite::params![client_commit_id],
7017 )
7018 .map_err(|error| error.to_string())?;
7019 self.conn
7020 .execute(
7021 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
7022 rusqlite::params![client_commit_id],
7023 )
7024 .map(|_| ())
7025 .map_err(|error| error.to_string())
7026 }
7027
7028 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
7029 match status {
7030 CommitOutcomeStatus::Applied => "applied",
7031 CommitOutcomeStatus::Cached => "cached",
7032 CommitOutcomeStatus::Conflict => "conflict",
7033 CommitOutcomeStatus::Rejected => "rejected",
7034 }
7035 }
7036
7037 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
7038 match resolution {
7039 CommitOutcomeResolution::Active => "active",
7040 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
7041 CommitOutcomeResolution::Superseded => "superseded",
7042 CommitOutcomeResolution::Dismissed => "dismissed",
7043 }
7044 }
7045
7046 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
7047 match value {
7048 "applied" => Ok(CommitOutcomeStatus::Applied),
7049 "cached" => Ok(CommitOutcomeStatus::Cached),
7050 "conflict" => Ok(CommitOutcomeStatus::Conflict),
7051 "rejected" => Ok(CommitOutcomeStatus::Rejected),
7052 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
7053 }
7054 }
7055
7056 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
7057 match value {
7058 "active" => Ok(CommitOutcomeResolution::Active),
7059 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
7060 "superseded" => Ok(CommitOutcomeResolution::Superseded),
7061 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
7062 _ => Err(format!(
7063 "invalid persisted commit outcome resolution {value:?}"
7064 )),
7065 }
7066 }
7067
7068 fn persist_commit_outcome(
7069 &self,
7070 client_commit_id: &str,
7071 status: CommitOutcomeStatus,
7072 results: &[CommitOperationOutcome],
7073 operations: Option<&[OutboxOp]>,
7074 ) -> Result<(), String> {
7075 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
7076 let operations_json = operations
7077 .map(|items| {
7078 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
7079 })
7080 .transpose()
7081 .map_err(|error| error.to_string())?;
7082 self.conn
7083 .execute(
7084 "INSERT INTO _syncular_commit_outcomes (
7085 client_commit_id, status, recorded_at_ms, results_json,
7086 operations_json, resolution
7087 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
7088 rusqlite::params![
7089 client_commit_id,
7090 Self::outcome_status_name(status),
7091 self.clock_now_ms(),
7092 results_json,
7093 operations_json
7094 ],
7095 )
7096 .map(|_| ())
7097 .map_err(|error| error.to_string())
7098 }
7099
7100 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
7101 let StoredCommitOutcomeRow {
7102 sequence,
7103 client_commit_id,
7104 status,
7105 recorded_at_ms,
7106 results_json,
7107 operations_json,
7108 resolution,
7109 resolved_at_ms,
7110 replacement_client_commit_id,
7111 } = row;
7112 Ok(CommitOutcome {
7113 sequence,
7114 client_commit_id,
7115 status: Self::parse_outcome_status(&status)?,
7116 recorded_at_ms,
7117 results: serde_json::from_str(&results_json)
7118 .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
7119 operations: operations_json
7120 .map(|value| {
7121 serde_json::from_str(&value).map_err(|error| {
7122 format!("invalid persisted commit outcome operations: {error}")
7123 })
7124 })
7125 .transpose()?,
7126 resolution: Self::parse_outcome_resolution(&resolution)?,
7127 resolved_at_ms,
7128 replacement_client_commit_id,
7129 })
7130 }
7131
7132 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
7133 let row = self
7134 .conn
7135 .query_row(
7136 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7137 resolution, resolved_at_ms, replacement_client_commit_id
7138 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
7139 rusqlite::params![client_commit_id],
7140 |row| {
7141 Ok(StoredCommitOutcomeRow {
7142 sequence: row.get(0)?,
7143 client_commit_id: row.get(1)?,
7144 status: row.get(2)?,
7145 recorded_at_ms: row.get(3)?,
7146 results_json: row.get(4)?,
7147 operations_json: row.get(5)?,
7148 resolution: row.get(6)?,
7149 resolved_at_ms: row.get(7)?,
7150 replacement_client_commit_id: row.get(8)?,
7151 })
7152 },
7153 )
7154 .optional()
7155 .map_err(|error| error.to_string())?;
7156 row.map(Self::outcome_from_row).transpose()
7157 }
7158
7159 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
7160 if query.limit == Some(0) {
7161 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
7162 }
7163 let mut sql = String::from(
7164 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7165 resolution, resolved_at_ms, replacement_client_commit_id
7166 FROM _syncular_commit_outcomes",
7167 );
7168 if query.active_only {
7169 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
7170 }
7171 sql.push_str(" ORDER BY seq DESC");
7172 if let Some(limit) = query.limit {
7173 sql.push_str(&format!(" LIMIT {limit}"));
7174 }
7175 let mut stmt = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
7176 let rows = stmt
7177 .query_map([], |row| {
7178 Ok(StoredCommitOutcomeRow {
7179 sequence: row.get(0)?,
7180 client_commit_id: row.get(1)?,
7181 status: row.get(2)?,
7182 recorded_at_ms: row.get(3)?,
7183 results_json: row.get(4)?,
7184 operations_json: row.get(5)?,
7185 resolution: row.get(6)?,
7186 resolved_at_ms: row.get(7)?,
7187 replacement_client_commit_id: row.get(8)?,
7188 })
7189 })
7190 .map_err(|error| error.to_string())?;
7191 let mut outcomes = Vec::new();
7192 for row in rows {
7193 outcomes.push(Self::outcome_from_row(
7194 row.map_err(|error| error.to_string())?,
7195 )?);
7196 }
7197 Ok(outcomes)
7198 }
7199
7200 fn prune_commit_outcomes(&self) -> Result<(), String> {
7201 #[cfg(test)]
7202 self.outcome_prune_count
7203 .set(self.outcome_prune_count.get() + 1);
7204 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
7205 let count = self
7206 .conn
7207 .query_row(
7208 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
7209 [],
7210 |row| row.get::<_, i64>(0),
7211 )
7212 .map_err(|error| error.to_string())? as usize;
7213 let excess = count.saturating_sub(max_entries);
7214 if excess == 0 {
7215 return Ok(());
7216 }
7217 let mut stmt = self
7218 .conn
7219 .prepare(
7220 "SELECT seq FROM _syncular_commit_outcomes
7221 WHERE status IN ('applied', 'cached') OR resolution != 'active'
7222 ORDER BY seq ASC LIMIT ?1",
7223 )
7224 .map_err(|error| error.to_string())?;
7225 let rows = stmt
7226 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
7227 .map_err(|error| error.to_string())?;
7228 let sequences = rows
7229 .collect::<Result<Vec<_>, _>>()
7230 .map_err(|error| error.to_string())?;
7231 drop(stmt);
7232 for sequence in sequences {
7233 self.conn
7234 .execute(
7235 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
7236 rusqlite::params![sequence],
7237 )
7238 .map_err(|error| error.to_string())?;
7239 }
7240 Ok(())
7241 }
7242
7243 pub fn subscribe(
7246 &mut self,
7247 id: String,
7248 table: String,
7249 scopes: Vec<(String, Vec<String>)>,
7250 params: Option<String>,
7251 ) -> Result<(), String> {
7252 if self.schema.table(&table).is_none() {
7253 return Err(format!("unknown table {table:?}"));
7254 }
7255 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
7256 let same_intent = existing.table == table
7257 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
7258 && existing.params == params;
7259 if same_intent {
7260 return Ok(());
7261 }
7262 return Err(
7263 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
7264 .to_owned(),
7265 );
7266 }
7267 let sub = Subscription {
7268 id: id.clone(),
7269 table,
7270 requested: scopes,
7271 params,
7272 cursor: -1,
7273 bootstrap_state: None,
7274 state: SubState::Active,
7275 reason_code: None,
7276 effective: None,
7277 synced_once: false,
7278 };
7279 self.persist_sub(&sub)?;
7280 self.subs.push(sub);
7281 Ok(())
7282 }
7283
7284 pub fn unsubscribe(&mut self, id: &str) {
7285 self.subs.retain(|s| s.id != id);
7286 let _ = self.conn.execute(
7287 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7288 rusqlite::params![id],
7289 );
7290 }
7291
7292 pub fn set_window(
7300 &mut self,
7301 base: &WindowBase,
7302 units: &[String],
7303 ) -> Result<CommandEffects, String> {
7304 let table = self
7305 .schema
7306 .table(&base.table)
7307 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
7308 if table.scope_column(&base.variable).is_none() {
7309 return Err(format!(
7310 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
7311 base.table, base.variable
7312 ));
7313 }
7314 let base_key = window_base_key(base);
7315 let wanted: std::collections::HashSet<&String> = units.iter().collect();
7316 let live = self.load_window_units(&base_key);
7317 self.begin_observation("syncular_window")?;
7318 let mut batch = ChangeAccumulator::default();
7319 let mut changed = false;
7320
7321 let prior_subs = self.subs.clone();
7322 let widened = (|| {
7323 for unit in units {
7325 if live.iter().any(|(u, _)| u == unit) {
7326 continue;
7327 }
7328 let sub_id = derive_sub_id(base, unit);
7329 self.delete_pending_evict(&sub_id)?;
7330 self.insert_window_unit(&base_key, unit, &sub_id)?;
7331 self.subscribe(
7332 sub_id,
7333 base.table.clone(),
7334 unit_scopes(base, unit),
7335 base.params.clone(),
7336 )?;
7337 batch.window(&base_key, &base.table, unit);
7338 changed = true;
7339 }
7340
7341 self.finish_observation("syncular_window", batch)
7342 })();
7343 if let Err(error) = widened {
7344 self.rollback_observation("syncular_window");
7345 self.subs = prior_subs;
7346 return Err(error);
7347 }
7348 for (unit, sub_id) in live {
7349 if wanted.contains(&unit) {
7350 continue;
7351 }
7352 self.rebuild_overlay_if_dirty();
7353 let prior_subs = self.subs.clone();
7354 self.begin_observation("syncular_window")?;
7355 let effective = self
7356 .subs
7357 .iter()
7358 .find(|sub| sub.id == sub_id)
7359 .and_then(|sub| sub.effective.clone())
7360 .unwrap_or_else(|| unit_scopes(base, &unit));
7361 let mut batch = ChangeAccumulator::default();
7362 self.record_scope_map(&mut batch, &base.table, &effective);
7363 batch.window(&base_key, &base.table, &unit);
7364 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
7365 let remaining = match result {
7366 Ok(remaining) => remaining,
7367 Err(error) => {
7368 self.rollback_observation("syncular_window");
7369 self.subs = prior_subs;
7370 return Err(error);
7371 }
7372 };
7373 if let Err(error) = self.finish_observation("syncular_window", batch) {
7374 self.rollback_observation("syncular_window");
7375 self.subs = prior_subs;
7376 return Err(error);
7377 }
7378 if remaining {
7379 self.drain_pending_evictions()?;
7380 }
7381 changed = true;
7382 }
7383 Ok(if changed {
7384 CommandEffects::interactive()
7385 } else {
7386 CommandEffects::none()
7387 })
7388 }
7389
7390 pub fn window_state(&self, base: &WindowBase) -> WindowState {
7395 let mut units = Vec::new();
7396 let mut pending = Vec::new();
7397 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
7398 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
7399 Some(sub) => {
7400 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
7401 }
7402 None => true,
7403 };
7404 if is_pending {
7405 pending.push(unit.clone());
7406 }
7407 units.push(unit);
7408 }
7409 WindowState { units, pending }
7410 }
7411
7412 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
7413 let mut stmt = match self
7414 .conn
7415 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
7416 {
7417 Ok(stmt) => stmt,
7418 Err(_) => return Vec::new(),
7419 };
7420 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
7421 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7422 });
7423 match rows {
7424 Ok(rows) => rows.filter_map(Result::ok).collect(),
7425 Err(_) => Vec::new(),
7426 }
7427 }
7428
7429 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
7430 let mut stmt = match self.conn.prepare(
7431 "SELECT windows.base, windows.unit, subscriptions.tbl
7432 FROM _syncular_windows AS windows
7433 JOIN _syncular_subscriptions AS subscriptions
7434 ON subscriptions.id = windows.sub_id
7435 ORDER BY windows.base, windows.unit",
7436 ) {
7437 Ok(stmt) => stmt,
7438 Err(_) => return Vec::new(),
7439 };
7440 let rows = stmt.query_map([], |row| {
7441 Ok((
7442 row.get::<_, String>(0)?,
7443 row.get::<_, String>(1)?,
7444 row.get::<_, String>(2)?,
7445 ))
7446 });
7447 match rows {
7448 Ok(rows) => rows.filter_map(Result::ok).collect(),
7449 Err(_) => Vec::new(),
7450 }
7451 }
7452
7453 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
7454 self.conn
7455 .query_row(
7456 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
7457 rusqlite::params![sub_id],
7458 |row| Ok((row.get(0)?, row.get(1)?)),
7459 )
7460 .ok()
7461 }
7462
7463 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
7464 self.conn
7465 .execute(
7466 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
7467 rusqlite::params![base_key, unit, sub_id],
7468 )
7469 .map(|_| ())
7470 .map_err(|error| error.to_string())
7471 }
7472
7473 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
7474 self.conn
7475 .execute(
7476 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
7477 rusqlite::params![base_key, unit],
7478 )
7479 .map(|_| ())
7480 .map_err(|error| error.to_string())
7481 }
7482
7483 fn evict_unit(
7490 &mut self,
7491 base_key: &str,
7492 base: &WindowBase,
7493 unit: &str,
7494 sub_id: &str,
7495 ) -> Result<bool, String> {
7496 let effective = self
7497 .subs
7498 .iter()
7499 .find(|s| s.id == sub_id)
7500 .and_then(|s| s.effective.clone())
7501 .unwrap_or_else(|| unit_scopes(base, unit));
7502 let pinned = self.pinned_row_ids(&base.table);
7503 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
7504 self.delete_window_unit(base_key, unit)?;
7505 self.conn
7506 .execute(
7507 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7508 [sub_id],
7509 )
7510 .map_err(|error| error.to_string())?;
7511 self.subs.retain(|sub| sub.id != sub_id);
7512 if deferred {
7513 self.save_pending_evict(sub_id, &base.table, &effective)?;
7514 } else {
7515 self.delete_pending_evict(sub_id)?;
7516 }
7517 Ok(remaining)
7518 }
7519
7520 fn evict_scope_rows(
7524 &self,
7525 table_name: &str,
7526 effective: &[(String, Vec<String>)],
7527 pinned: &std::collections::HashSet<String>,
7528 ) -> Result<(bool, bool), String> {
7529 if effective.is_empty() {
7530 return Ok((false, false));
7531 }
7532 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
7533 let mut clauses = Vec::new();
7534 let mut params: Vec<SqlValue> = Vec::new();
7535 for (variable, values) in effective {
7536 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
7537 if values.is_empty() {
7538 return Ok((false, false));
7539 }
7540 let holes = values
7541 .iter()
7542 .map(|v| {
7543 params.push(SqlValue::Text(v.clone()));
7544 "?"
7545 })
7546 .collect::<Vec<_>>()
7547 .join(", ");
7548 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
7549 }
7550 let scope = clauses.join(" AND ");
7551 let scope_params = params.len();
7552 let pk = quote_ident(&table.primary_key);
7553 if !pinned.is_empty() {
7554 let holes = pinned
7555 .iter()
7556 .map(|id| {
7557 params.push(SqlValue::Text(id.clone()));
7558 "?"
7559 })
7560 .collect::<Vec<_>>()
7561 .join(", ");
7562 clauses.push(format!("{pk} NOT IN ({holes})"));
7563 }
7564 let predicate = clauses.join(" AND ");
7565 let base = base_table(table_name);
7566 let visible = visible_table(table_name);
7567 let mut ids: Vec<SqlValue> = Vec::new();
7568 for source in [&base, &visible] {
7569 let mut stmt = self
7570 .conn
7571 .prepare_cached(&format!(
7572 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
7573 ))
7574 .map_err(|e| e.to_string())?;
7575 ids = stmt
7576 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
7577 .map_err(|e| e.to_string())?
7578 .collect::<Result<_, _>>()
7579 .map_err(|e| e.to_string())?;
7580 if !ids.is_empty() {
7581 break;
7582 }
7583 }
7584 if !ids.is_empty() {
7585 let holes = vec!["?"; ids.len()].join(", ");
7586 for source in [&base, &visible] {
7587 self.conn
7588 .execute(
7589 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
7590 rusqlite::params_from_iter(&ids),
7591 )
7592 .map_err(|e| e.to_string())?;
7593 }
7594 }
7595 let mut remaining = false;
7596 let mut deferred = false;
7597 for source in [&base, &visible] {
7598 remaining |= self
7599 .conn
7600 .query_row(
7601 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
7602 rusqlite::params_from_iter(¶ms),
7603 |row| row.get::<_, bool>(0),
7604 )
7605 .map_err(|e| e.to_string())?;
7606 deferred |= self
7607 .conn
7608 .query_row(
7609 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
7610 rusqlite::params_from_iter(¶ms[..scope_params]),
7611 |row| row.get::<_, bool>(0),
7612 )
7613 .map_err(|e| e.to_string())?;
7614 }
7615 Ok((remaining, deferred))
7616 }
7617
7618 fn drain_pending_evictions(&mut self) -> Result<(), String> {
7620 let pending = self.load_pending_evictions()?;
7621 if pending.is_empty() {
7622 return Ok(());
7623 }
7624 self.rebuild_overlay_if_dirty();
7625 for (sub_id, table_name, effective) in pending {
7626 if self.schema.table(&table_name).is_none() {
7627 self.delete_pending_evict(&sub_id)?;
7628 continue;
7629 }
7630 loop {
7631 std::thread::yield_now();
7632 self.begin_observation("syncular_evict")?;
7633 let pinned = self.pinned_row_ids(&table_name);
7634 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
7635 let (remaining, deferred) = match result {
7636 Ok(result) => result,
7637 Err(error) => {
7638 self.rollback_observation("syncular_evict");
7639 return Err(error);
7640 }
7641 };
7642 if !deferred {
7643 if let Err(error) = self.delete_pending_evict(&sub_id) {
7644 self.rollback_observation("syncular_evict");
7645 return Err(error);
7646 }
7647 }
7648 let mut batch = ChangeAccumulator::default();
7649 self.record_scope_map(&mut batch, &table_name, &effective);
7650 if let Err(error) = self.finish_observation("syncular_evict", batch) {
7651 self.rollback_observation("syncular_evict");
7652 return Err(error);
7653 }
7654 if !remaining {
7655 break;
7656 }
7657 }
7658 }
7659 Ok(())
7660 }
7661
7662 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
7665 let mut pinned = std::collections::HashSet::new();
7666 for commit in &self.outbox {
7667 for op in &commit.ops {
7668 if op.table == table {
7669 pinned.insert(op.row_id.clone());
7670 }
7671 }
7672 }
7673 pinned
7674 }
7675
7676 fn save_pending_evict(
7677 &self,
7678 sub_id: &str,
7679 table: &str,
7680 effective: &[(String, Vec<String>)],
7681 ) -> Result<(), String> {
7682 self.conn.execute(
7683 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
7684 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
7685 ).map(|_| ()).map_err(|error| error.to_string())
7686 }
7687
7688 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
7689 self.conn
7690 .execute(
7691 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
7692 rusqlite::params![sub_id],
7693 )
7694 .map(|_| ())
7695 .map_err(|error| error.to_string())
7696 }
7697
7698 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
7699 let mut stmt = self
7700 .conn
7701 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
7702 .map_err(|error| error.to_string())?;
7703 let rows = stmt
7704 .query_map([], |row| {
7705 Ok((
7706 row.get::<_, String>(0)?,
7707 row.get::<_, String>(1)?,
7708 row.get::<_, String>(2)?,
7709 ))
7710 })
7711 .map_err(|error| error.to_string())?;
7712 rows.map(|row| {
7713 let (sub_id, table, json) = row.map_err(|error| error.to_string())?;
7714 let value: Value = serde_json::from_str(&json)
7715 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7716 let effective = json_to_scope_map(&value)
7717 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7718 Ok((sub_id, table, effective))
7719 })
7720 .collect()
7721 }
7722
7723 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
7725 if mutations.is_empty() {
7726 return Err("a commit must contain at least one operation (§6.1)".to_owned());
7727 }
7728 let mut ops = Vec::with_capacity(mutations.len());
7729 for mutation in mutations {
7730 match mutation {
7731 Mutation::Upsert {
7732 table,
7733 values,
7734 base_version,
7735 } => {
7736 let schema_table = self
7737 .schema
7738 .table(&table)
7739 .ok_or_else(|| format!("unknown table {table:?}"))?;
7740 let values = normalize_values_casing(schema_table, values)?;
7744 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
7745 let values = full_row_values(schema_table, values)?;
7748 self.validate_author_encode(schema_table, &row_id, &values)?;
7754 ops.push(OutboxOp {
7755 upsert: true,
7756 table,
7757 row_id,
7758 base_version,
7759 values: Some(values),
7760 });
7761 }
7762 Mutation::Delete {
7763 table,
7764 row_id,
7765 base_version,
7766 } => {
7767 if self.schema.table(&table).is_none() {
7768 return Err(format!("unknown table {table:?}"));
7769 }
7770 ops.push(OutboxOp {
7771 upsert: false,
7772 table,
7773 row_id,
7774 base_version,
7775 values: None,
7776 });
7777 }
7778 }
7779 }
7780 self.record_outbox_commit(ops)
7781 }
7782
7783 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
7784 let commit = OutboxCommit {
7785 client_commit_id: uuid::Uuid::new_v4().to_string(),
7786 ops,
7787 };
7788 self.begin_observation("syncular_mutation")?;
7789 let mut batch = ChangeAccumulator::default();
7790 for op in &commit.ops {
7791 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
7792 if let Some(values) = &op.values {
7793 if let Some(table) = self.schema.table(&op.table) {
7794 for scope in &table.scope_variables {
7795 if let Some(Value::String(value)) = values.get(&scope.column) {
7796 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
7797 precise = true;
7798 }
7799 }
7800 }
7801 }
7802 if !precise {
7803 batch.table(&op.table);
7804 }
7805 }
7806 if let Err(error) = self.persist_outbox_insert(&commit) {
7807 self.rollback_observation("syncular_mutation");
7808 return Err(error);
7809 }
7810 let was_dirty = self.overlay_dirty.get();
7811 if was_dirty {
7816 self.rebuild_overlay();
7817 }
7818 if let Some(error) = commit
7819 .ops
7820 .iter()
7821 .find_map(|op| self.apply_outbox_op(op).err())
7822 {
7823 self.rollback_observation("syncular_mutation");
7824 self.overlay_dirty.set(was_dirty);
7825 return Err(error);
7826 }
7827 let id = commit.client_commit_id.clone();
7828 self.outbox.push(commit);
7829 batch.status = true;
7830 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
7831 self.rollback_observation("syncular_mutation");
7832 self.outbox.pop();
7833 self.overlay_dirty.set(was_dirty);
7834 return Err(error);
7835 }
7836 Ok(id)
7837 }
7838
7839 pub fn patch(
7845 &mut self,
7846 table: &str,
7847 row_id: &str,
7848 partial: Map<String, Value>,
7849 base_version: Option<i64>,
7850 ) -> Result<String, String> {
7851 let schema_table = self
7852 .schema
7853 .table(table)
7854 .ok_or_else(|| format!("unknown table {table:?}"))?;
7855 let partial = normalize_values_casing(schema_table, partial)?;
7856 if let Some(pk) = partial.get(&schema_table.primary_key) {
7857 if pk.as_str() != Some(row_id) {
7858 return Err(format!(
7859 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
7860 ));
7861 }
7862 }
7863 for scope in &schema_table.scope_variables {
7873 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
7874 continue;
7875 }
7876 let Some(column) = schema_table
7877 .columns
7878 .iter()
7879 .find(|column| column.name == scope.column)
7880 else {
7881 continue;
7882 };
7883 let stored = self
7884 .conn
7885 .query_row(
7886 &format!(
7887 "SELECT {} FROM {} WHERE {}",
7888 quote_ident(&scope.column),
7889 visible_table(&schema_table.name),
7890 row_id_predicate(schema_table)
7891 ),
7892 rusqlite::params![row_id],
7893 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
7894 )
7895 .ok();
7896 let incoming = json_to_column_value(column, partial.get(&scope.column))
7901 .ok()
7902 .map(|value| column_value_to_json(&value));
7903 let equal = stored.as_ref().is_some_and(|stored| {
7907 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
7908 });
7909 if !equal {
7910 return Err(format!(
7911 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
7912 scope.column
7913 ));
7914 }
7915 }
7916 let mut values = partial;
7917 for scope in &schema_table.scope_variables {
7920 values.remove(&scope.column);
7921 }
7922 values.insert(
7923 schema_table.primary_key.clone(),
7924 Value::from(row_id.to_owned()),
7925 );
7926 self.validate_author_encode(schema_table, row_id, &values)?;
7932 self.record_outbox_commit(vec![OutboxOp {
7933 upsert: true,
7934 table: table.to_owned(),
7935 row_id: row_id.to_owned(),
7936 base_version,
7937 values: Some(values),
7938 }])
7939 }
7940
7941 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
7946 let selector = self.encryption.key_id_columns.get(&table.name)?;
7947 let index = table
7948 .columns
7949 .iter()
7950 .position(|column| &column.name == selector)?;
7951 let sql = format!(
7952 "SELECT {} FROM {} WHERE {}",
7953 quote_ident(selector),
7954 visible_table(&table.name),
7955 row_id_predicate(table)
7956 );
7957 let key: Option<String> = self
7958 .conn
7959 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
7960 .ok()?;
7961 let key = key.filter(|key| !key.is_empty())?;
7962 let mut fallback: Row = vec![None; table.columns.len()];
7963 fallback[index] = Some(ColumnValue::String(key));
7964 Some(fallback)
7965 }
7966
7967 fn validate_author_encode(
7972 &self,
7973 table: &TableSchema,
7974 row_id: &str,
7975 values: &Map<String, Value>,
7976 ) -> Result<(), String> {
7977 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
7978 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
7979 result => result.map(|_| ()),
7980 }
7981 }
7982
7983 fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
7989 if !self
7990 .schema
7991 .tables
7992 .iter()
7993 .any(|table| table.has_encrypted_columns())
7994 {
7995 return Ok(false);
7996 }
7997 let failures = self
7998 .outbox
7999 .iter()
8000 .filter_map(|commit| {
8001 commit
8002 .ops
8003 .iter()
8004 .find_map(|op| {
8005 let values = op.values.as_ref().filter(|_| op.upsert)?;
8006 let table = self.schema.table(&op.table)?;
8007 let fallback = self.stored_key_fallback(table, &op.row_id);
8008 match encode_sparse_row_json(
8009 table,
8010 &op.row_id,
8011 values,
8012 &self.encryption,
8013 fallback.as_ref(),
8014 ) {
8015 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
8016 _ => None,
8017 }
8018 })
8019 .map(|error| (commit.clone(), error))
8020 })
8021 .collect::<Vec<_>>();
8022 if failures.is_empty() {
8023 return Ok(false);
8024 }
8025 for (commit, message) in failures {
8026 let rejection = RejectionRecord {
8027 client_commit_id: commit.client_commit_id.clone(),
8028 op_index: 0,
8029 code: ENCRYPT_FAILED_CODE.to_owned(),
8030 message,
8031 retryable: false,
8032 details: None,
8033 operation: commit.ops.first().map(CommitOperation::from),
8034 };
8035 self.persist_commit_outcome(
8036 &commit.client_commit_id,
8037 CommitOutcomeStatus::Rejected,
8038 &[CommitOperationOutcome::Error {
8039 rejection: rejection.clone(),
8040 }],
8041 Some(&commit.ops),
8042 )?;
8043 self.delete_outbox_persisted(&commit.client_commit_id)?;
8044 self.outbox
8045 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
8046 self.rejections.push(rejection);
8047 }
8048 self.prune_commit_outcomes()?;
8049 self.overlay_dirty.set(true);
8050 self.rebuild_overlay();
8051 Ok(true)
8052 }
8053
8054 pub fn pending_commit_ids(&self) -> Vec<String> {
8055 self.outbox
8056 .iter()
8057 .map(|c| c.client_commit_id.clone())
8058 .collect()
8059 }
8060
8061 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
8065 let mut payloads = Vec::new();
8066 for commit in &self.outbox {
8067 for op in &commit.ops {
8068 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
8069 continue;
8070 };
8071 let Some(table) = self.schema.table(&op.table) else {
8072 continue;
8073 };
8074 let fallback = self.stored_key_fallback(table, &op.row_id);
8075 if let Ok(payload) = encode_sparse_row_json(
8076 table,
8077 &op.row_id,
8078 values,
8079 &self.encryption,
8080 fallback.as_ref(),
8081 ) {
8082 payloads.push(payload);
8083 }
8084 }
8085 }
8086 payloads
8087 }
8088
8089 pub fn conflicts(&self) -> &[ConflictRecord] {
8090 &self.conflicts
8091 }
8092
8093 pub fn rejections(&self) -> &[RejectionRecord] {
8094 &self.rejections
8095 }
8096
8097 pub fn resolve_commit_outcome(
8098 &mut self,
8099 input: ResolveCommitOutcomeInput,
8100 ) -> Result<CommitOutcome, String> {
8101 let current = self
8102 .commit_outcome(&input.client_commit_id)?
8103 .ok_or_else(|| {
8104 format!(
8105 "sync.outcome_not_found: no durable outcome exists for {:?}",
8106 input.client_commit_id
8107 )
8108 })?;
8109 if current.resolution != CommitOutcomeResolution::Active {
8110 return Ok(current);
8111 }
8112 if input.resolution == CommitOutcomeResolution::Active {
8113 return Err("sync.invalid_request: resolution must leave active state".to_owned());
8114 }
8115 match input.resolution {
8116 CommitOutcomeResolution::Superseded => {
8117 let replacement = input
8118 .replacement_client_commit_id
8119 .as_deref()
8120 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
8121 .ok_or_else(|| {
8122 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
8123 .to_owned()
8124 })?;
8125 let _ = replacement;
8126 }
8127 _ if input.replacement_client_commit_id.is_some() => {
8128 return Err(
8129 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
8130 .to_owned(),
8131 );
8132 }
8133 _ => {}
8134 }
8135 let allowed = match current.status {
8136 CommitOutcomeStatus::Conflict => matches!(
8137 input.resolution,
8138 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
8139 ),
8140 CommitOutcomeStatus::Rejected => {
8141 input.resolution == CommitOutcomeResolution::Superseded
8142 }
8143 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
8144 input.resolution == CommitOutcomeResolution::Dismissed
8145 }
8146 };
8147 if !allowed {
8148 return Err(format!(
8149 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
8150 input.resolution, current.status
8151 ));
8152 }
8153
8154 self.begin_observation("syncular_outcome_resolution")?;
8155 let result = (|| {
8156 self.conn
8157 .execute(
8158 "UPDATE _syncular_commit_outcomes
8159 SET resolution = ?1, resolved_at_ms = ?2,
8160 replacement_client_commit_id = ?3
8161 WHERE client_commit_id = ?4 AND resolution = 'active'",
8162 rusqlite::params![
8163 Self::outcome_resolution_name(input.resolution),
8164 self.clock_now_ms(),
8165 input.replacement_client_commit_id,
8166 input.client_commit_id
8167 ],
8168 )
8169 .map_err(|error| error.to_string())?;
8170 let resolved = self
8171 .commit_outcome(¤t.client_commit_id)?
8172 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
8173 self.prune_commit_outcomes()?;
8174 Ok(resolved)
8175 })();
8176 let resolved = match result {
8177 Ok(outcome) => outcome,
8178 Err(error) => {
8179 self.rollback_observation("syncular_outcome_resolution");
8180 return Err(error);
8181 }
8182 };
8183 self.conflicts
8184 .retain(|record| record.client_commit_id != current.client_commit_id);
8185 self.rejections
8186 .retain(|record| record.client_commit_id != current.client_commit_id);
8187 let batch = ChangeAccumulator {
8188 conflicts: current.status == CommitOutcomeStatus::Conflict,
8189 rejections: current.status == CommitOutcomeStatus::Rejected,
8190 outcomes: true,
8191 ..ChangeAccumulator::default()
8192 };
8193 if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
8194 self.rollback_observation("syncular_outcome_resolution");
8195 return Err(error);
8196 }
8197 Ok(resolved)
8198 }
8199
8200 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
8201 self.schema_floor.as_ref()
8202 }
8203
8204 pub fn lease_state(&self) -> Option<&LeaseState> {
8206 self.lease_state.as_ref()
8207 }
8208
8209 fn record_lease_error(&mut self, code: &str) {
8212 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
8213 return;
8214 }
8215 let mut next = self.lease_state.clone().unwrap_or_default();
8216 next.error_code = Some(code.to_owned());
8217 self.set_lease_state(Some(next));
8218 }
8219
8220 fn set_lease_state(&mut self, next: Option<LeaseState>) {
8221 if self.lease_state == next {
8222 return;
8223 }
8224 if self.begin_observation("syncular_lease").is_err() {
8225 return;
8226 }
8227 self.lease_state = next;
8228 if let Some(lease) = &self.lease_state {
8229 if let Ok(json) = serde_json::to_string(lease) {
8230 self.set_meta(LEASE_STATE_KEY, &json);
8231 }
8232 } else {
8233 self.delete_meta(LEASE_STATE_KEY);
8234 }
8235 let batch = ChangeAccumulator {
8236 status: true,
8237 ..ChangeAccumulator::default()
8238 };
8239 if self.finish_observation("syncular_lease", batch).is_err() {
8240 self.rollback_observation("syncular_lease");
8241 }
8242 }
8243
8244 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
8245 if self.schema_floor == next {
8246 return;
8247 }
8248 if self.begin_observation("syncular_schema_floor").is_err() {
8249 return;
8250 }
8251 self.schema_floor = next;
8252 self.stopped = self.schema_floor.is_some();
8253 if let Some(floor) = &self.schema_floor {
8254 if let Ok(json) = serde_json::to_string(floor) {
8255 self.set_meta(SCHEMA_FLOOR_KEY, &json);
8256 }
8257 } else {
8258 self.delete_meta(SCHEMA_FLOOR_KEY);
8259 }
8260 let batch = ChangeAccumulator {
8261 status: true,
8262 ..ChangeAccumulator::default()
8263 };
8264 if self
8265 .finish_observation("syncular_schema_floor", batch)
8266 .is_err()
8267 {
8268 self.rollback_observation("syncular_schema_floor");
8269 }
8270 }
8271
8272 fn set_upgrading(&mut self, value: bool) {
8273 if self.upgrading == value {
8274 return;
8275 }
8276 if self.begin_observation("syncular_upgrading").is_err() {
8277 return;
8278 }
8279 self.upgrading = value;
8280 let batch = ChangeAccumulator {
8281 status: true,
8282 ..ChangeAccumulator::default()
8283 };
8284 if self
8285 .finish_observation("syncular_upgrading", batch)
8286 .is_err()
8287 {
8288 self.rollback_observation("syncular_upgrading");
8289 }
8290 }
8291
8292 pub fn sync_needed(&self) -> bool {
8293 self.sync_needed
8294 }
8295
8296 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
8297 let sub = self.subs.iter().find(|s| s.id == id)?;
8298 Some(SubscriptionStateView {
8299 id: sub.id.clone(),
8300 table: sub.table.clone(),
8301 status: sub.state.name().to_owned(),
8302 cursor: sub.cursor,
8303 has_resume_token: sub.bootstrap_state.is_some(),
8304 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
8305 reason_code: sub.reason_code.clone(),
8306 })
8307 }
8308
8309 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
8310 let schema_table = self
8311 .schema
8312 .table(table)
8313 .ok_or_else(|| format!("unknown table {table:?}"))?;
8314 let sql = format!(
8315 "SELECT * FROM {} ORDER BY {} ASC",
8316 visible_table(table),
8317 quote_ident(&schema_table.primary_key)
8318 );
8319 let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
8320 let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
8321 let mut out = Vec::new();
8322 while let Some(row) = rows.next().map_err(|e| e.to_string())? {
8323 let mut values = Map::new();
8324 for (i, column) in schema_table.columns.iter().enumerate() {
8325 let value = row.get_ref(i).map_err(|e| e.to_string())?;
8326 values.insert(column.name.clone(), sql_ref_to_json(column, value));
8327 }
8328 let version: i64 = row
8329 .get(schema_table.columns.len())
8330 .map_err(|e| e.to_string())?;
8331 let row_id = match values.get(&schema_table.primary_key) {
8332 Some(Value::String(s)) => s.clone(),
8333 Some(Value::Number(n)) => n.to_string(),
8334 Some(Value::Bool(b)) => b.to_string(),
8335 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
8336 };
8337 out.push(RowState {
8338 row_id,
8339 version,
8340 values,
8341 });
8342 }
8343 Ok(out)
8344 }
8345
8346 #[cfg(feature = "crdt-yjs")]
8362 fn crdt_column_bytes(
8363 &self,
8364 table: &str,
8365 row_id: &str,
8366 column: &str,
8367 ) -> Result<Option<Vec<u8>>, String> {
8368 let schema_table = self
8369 .schema
8370 .table(table)
8371 .ok_or_else(|| format!("unknown table {table:?}"))?;
8372 let col = schema_table
8373 .columns
8374 .iter()
8375 .find(|c| c.name == column)
8376 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
8377 if col.ty != ColumnType::Crdt {
8378 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
8379 }
8380 let sql = format!(
8381 "SELECT {} FROM {} WHERE {}",
8382 quote_ident(column),
8383 visible_table(table),
8384 row_id_predicate(schema_table)
8385 );
8386 let bytes: Option<Vec<u8>> = self
8387 .conn
8388 .prepare_cached(&sql)
8389 .map_err(|error| error.to_string())?
8390 .query_row(rusqlite::params![row_id], |row| {
8391 row.get::<_, Option<Vec<u8>>>(0)
8392 })
8393 .map_err(|e| match e {
8394 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
8395 other => other.to_string(),
8396 })?;
8397 Ok(bytes)
8398 }
8399
8400 #[cfg(feature = "crdt-yjs")]
8404 pub fn crdt_text(
8405 &self,
8406 table: &str,
8407 row_id: &str,
8408 column: &str,
8409 name: &str,
8410 ) -> Result<String, String> {
8411 let bytes = self
8412 .crdt_column_bytes(table, row_id, column)?
8413 .unwrap_or_default();
8414 crate::crdt::text(&bytes, name)
8415 }
8416
8417 #[cfg(feature = "crdt-yjs")]
8421 pub fn crdt_insert_text(
8422 &mut self,
8423 table: &str,
8424 row_id: &str,
8425 column: &str,
8426 name: &str,
8427 index: u32,
8428 value: &str,
8429 ) -> Result<String, String> {
8430 let current = self
8431 .crdt_column_bytes(table, row_id, column)?
8432 .unwrap_or_default();
8433 let update = crate::crdt::insert_text(¤t, name, index, value)?;
8434 self.crdt_push_update(table, row_id, column, &update)
8435 }
8436
8437 #[cfg(feature = "crdt-yjs")]
8441 pub fn crdt_delete_text(
8442 &mut self,
8443 table: &str,
8444 row_id: &str,
8445 column: &str,
8446 name: &str,
8447 index: u32,
8448 len: u32,
8449 ) -> Result<String, String> {
8450 let current = self
8451 .crdt_column_bytes(table, row_id, column)?
8452 .unwrap_or_default();
8453 let update = crate::crdt::delete_text(¤t, name, index, len)?;
8454 self.crdt_push_update(table, row_id, column, &update)
8455 }
8456
8457 #[cfg(feature = "crdt-yjs")]
8462 pub fn crdt_apply_update(
8463 &mut self,
8464 table: &str,
8465 row_id: &str,
8466 column: &str,
8467 update: &[u8],
8468 ) -> Result<String, String> {
8469 let current = self
8470 .crdt_column_bytes(table, row_id, column)?
8471 .unwrap_or_default();
8472 let next = crate::crdt::apply_update(¤t, update)?;
8473 self.crdt_push_update(table, row_id, column, &next)
8474 }
8475
8476 #[cfg(feature = "crdt-yjs")]
8483 fn crdt_push_update(
8484 &mut self,
8485 table: &str,
8486 row_id: &str,
8487 column: &str,
8488 crdt_bytes: &[u8],
8489 ) -> Result<String, String> {
8490 let mut bytes_obj = Map::new();
8491 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
8492 let mut partial = Map::new();
8493 partial.insert(column.to_owned(), Value::Object(bytes_obj));
8494 self.patch(table, row_id, partial, None)
8495 }
8496
8497 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
8512 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
8513 }
8514
8515 #[cfg(feature = "bench-internals")]
8517 #[doc(hidden)]
8518 pub fn benchmark_connection(&mut self) -> &mut Connection {
8519 &mut self.conn
8520 }
8521
8522 #[cfg(feature = "bench-internals")]
8524 #[doc(hidden)]
8525 pub fn benchmark_phases(
8526 &self,
8527 enabled: Option<bool>,
8528 reset: bool,
8529 ) -> Result<Option<Value>, String> {
8530 self.benchmark_phases.configure(enabled, reset)?;
8531 Ok(self.benchmark_phases.snapshot())
8532 }
8533
8534 pub fn query_snapshot(
8538 &mut self,
8539 sql: &str,
8540 params: &[QueryValue],
8541 coverage: &[WindowCoverage],
8542 owner: Option<QueryOwner<'_>>,
8543 ) -> Result<QuerySnapshot, String> {
8544 let result = snapshot_connection(&self.conn, sql, params, coverage);
8545 if let Some(owner) = owner {
8546 self.record_query_read(owner, result.as_ref().err());
8547 }
8548 result.map_err(|failure| failure.to_string())
8549 }
8550
8551 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
8555 let position = self
8556 .query_failures
8557 .iter()
8558 .position(|entry| entry.id == owner.id);
8559 let Some(failure) = failure else {
8560 if let Some(index) = position {
8561 self.query_failures.remove(index);
8562 }
8563 return;
8564 };
8565 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
8566 .into_iter()
8567 .map(str::to_owned)
8568 .collect();
8569 let code = failure.code.unwrap_or("client.query_failed").to_owned();
8570 if position.is_some_and(|index| {
8571 let entry = &self.query_failures[index];
8572 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
8573 }) {
8574 return;
8575 }
8576 if let Some(index) = position {
8577 self.query_failures.remove(index);
8578 }
8579 self.query_failures.push(DiagnosticQueryFailure {
8580 id: owner.id.to_owned(),
8581 tables,
8582 code,
8583 sqlite_code: failure.sqlite_code,
8584 at_ms: self.clock_now_ms(),
8585 });
8586 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
8587 self.query_failures.remove(0);
8588 }
8589 }
8590
8591 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
8594 #[cfg(feature = "bench-internals")]
8595 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
8596 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
8597 let mut frames = vec![Frame::ReqHeader {
8598 client_id: self.client_id.clone(),
8599 schema_version: self.schema.version,
8600 log_epoch: log_epoch.clone(),
8601 }];
8602 let mut pushed_ids = Vec::new();
8603 let mut ops_in_request = 0usize;
8604 let mut deferred_commits = 0usize;
8605 #[cfg(feature = "bench-internals")]
8606 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
8607 for (index, commit) in self
8608 .outbox
8609 .iter()
8610 .take(if log_epoch.is_some() {
8611 self.outbox.len()
8612 } else {
8613 0
8614 })
8615 .enumerate()
8616 {
8617 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
8622 deferred_commits = self.outbox.len() - index;
8623 break;
8624 }
8625 ops_in_request += commit.ops.len();
8626 let operations = commit
8627 .ops
8628 .iter()
8629 .map(|op| {
8630 let payload = op.values.as_ref().and_then(|values| {
8631 let table = self.schema.table(&op.table)?;
8632 let fallback = self.stored_key_fallback(table, &op.row_id);
8641 encode_sparse_row_json(
8642 table,
8643 &op.row_id,
8644 values,
8645 &self.encryption,
8646 fallback.as_ref(),
8647 )
8648 .ok()
8649 });
8650 ssp2::model::Operation {
8651 table: op.table.clone(),
8652 row_id: op.row_id.clone(),
8653 op: if op.upsert { Op::Upsert } else { Op::Delete },
8654 base_version: op.base_version,
8655 payload,
8656 }
8657 })
8658 .collect();
8659 frames.push(Frame::PushCommit {
8660 client_commit_id: commit.client_commit_id.clone(),
8661 operations,
8662 });
8663 pushed_ids.push(commit.client_commit_id.clone());
8664 }
8665 #[cfg(feature = "bench-internals")]
8666 drop(outbox_phase);
8667 let accept = self.limits.accept.unwrap_or(if url_capable {
8670 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
8671 } else {
8672 DEFAULT_ACCEPT
8673 });
8674 frames.push(Frame::PullHeader {
8675 limit_commits: self.limits.limit_commits.unwrap_or(0),
8676 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
8677 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
8678 accept,
8679 });
8680 let mut fresh = Vec::new();
8681 for sub in &self.subs {
8682 if sub.state != SubState::Active {
8683 continue;
8684 }
8685 let mut scopes = sub.requested.clone();
8686 sort_scope_map(&mut scopes);
8687 frames.push(Frame::Subscription {
8688 id: sub.id.clone(),
8689 table: sub.table.clone(),
8690 scopes,
8691 params: sub.params.clone().map(RawJson),
8692 cursor: sub.cursor,
8693 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
8694 });
8695 fresh.push((
8696 sub.id.clone(),
8697 sub.cursor < 0 && sub.bootstrap_state.is_none(),
8698 ));
8699 }
8700 let message = Message {
8701 wire_version: WIRE_VERSION,
8702 msg_kind: MsgKind::Request,
8703 frames,
8704 };
8705 (
8706 message,
8707 RequestMeta {
8708 pushed_ids,
8709 fresh,
8710 accept,
8711 deferred_commits,
8712 },
8713 )
8714 }
8715
8716 pub fn progress(&self) -> ProgressObserver {
8720 self.progress.clone()
8721 }
8722
8723 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
8724 self.progress.start();
8725 let started_at_ms = self.clock_now_ms();
8726 let outcome = self.sync_inner(transport);
8727 self.progress.update(|p| match &outcome {
8728 SyncOutcome::Ok(report) if report.failed.is_empty() => {
8729 p.state = ProgressState::Complete
8730 }
8731 SyncOutcome::Ok(_) => {
8732 p.state = ProgressState::Failed;
8733 p.error_code = Some("sync.scope_revoked".into());
8734 }
8735 SyncOutcome::Failed { error_code, .. } => {
8736 p.state = ProgressState::Failed;
8737 p.error_code = Some(Self::diagnostic_code(error_code));
8738 }
8739 });
8740 let completed_at_ms = self.clock_now_ms();
8741 self.last_round = Some(match &outcome {
8742 SyncOutcome::Ok(report) => DiagnosticLastRound {
8743 status: "succeeded".to_owned(),
8744 started_at_ms,
8745 completed_at_ms,
8746 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
8747 counters: Some(DiagnosticRoundCounters {
8748 pushed: report.pushed,
8749 applied: report.applied.len(),
8750 rejected: report.rejected.len(),
8751 retryable: report.retryable.len(),
8752 conflicts: report.conflicts,
8753 commits_applied: report.commits_applied,
8754 segment_rows_applied: report.segment_rows_applied,
8755 bootstrapping: report.bootstrapping.len(),
8756 resets: report.resets.len(),
8757 revoked: report.revoked.len(),
8758 failed: report.failed.len(),
8759 deferred_commits: report.deferred_commits,
8760 }),
8761 error_code: None,
8762 },
8763 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
8764 status: "failed".to_owned(),
8765 started_at_ms,
8766 completed_at_ms,
8767 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
8768 counters: None,
8769 error_code: Some(Self::diagnostic_code(error_code)),
8770 },
8771 });
8772 outcome
8773 }
8774
8775 fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
8776 if self.stopped {
8777 return SyncOutcome::Ok(SyncReport {
8780 schema_floor: self.schema_floor.clone(),
8781 ..SyncReport::default()
8782 });
8783 }
8784 self.set_sync_needed(false, false);
8787 if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
8790 if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
8791 if Self::retryable_transport_code(&code) {
8792 self.schedule_background_retry();
8793 }
8794 return SyncOutcome::Failed {
8795 error_code: code,
8796 message,
8797 };
8798 }
8799 }
8800 if let Err(message) = self.drain_pending_evictions() {
8801 return SyncOutcome::Failed {
8802 error_code: "storage.failed".into(),
8803 message,
8804 };
8805 }
8806 if let Err(message) = self.drop_unencodable_outbox() {
8810 return SyncOutcome::Failed {
8811 error_code: "storage.failed".into(),
8812 message,
8813 };
8814 }
8815 let (message, meta) = self.build_request(transport.supports_url_fetch());
8816 let request_bytes = {
8817 #[cfg(feature = "bench-internals")]
8818 let _phase = self.benchmark_phases.start(Phase::RequestEncode);
8819 encode_message(&message)
8820 };
8821 let round = if self.realtime_connected {
8825 transport.realtime_sync(&request_bytes)
8826 } else {
8827 transport.sync(&request_bytes)
8828 };
8829 let response_bytes = match round {
8830 Ok(bytes) => bytes,
8831 Err(TransportError { code, message }) => {
8832 self.record_lease_error(&code);
8835 if Self::retryable_transport_code(&code) {
8836 self.schedule_background_retry();
8837 }
8838 return SyncOutcome::Failed {
8839 error_code: code,
8840 message,
8841 };
8842 }
8843 };
8844 #[cfg(feature = "bench-internals")]
8845 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
8846 let response = match decode_message(&response_bytes) {
8847 Ok(message) => message,
8848 Err(error) => {
8849 return SyncOutcome::Failed {
8852 error_code: error.code.as_str().to_owned(),
8853 message: error.detail,
8854 };
8855 }
8856 };
8857 #[cfg(feature = "bench-internals")]
8858 drop(decode_phase);
8859 if response.msg_kind != MsgKind::Response {
8860 return SyncOutcome::Failed {
8861 error_code: "sync.invalid_request".to_owned(),
8862 message: "expected a response message".to_owned(),
8863 };
8864 }
8865 let mut outcome = self.process_response(transport, response, &meta);
8866 if let SyncOutcome::Ok(report) = &mut outcome {
8867 report.deferred_commits = meta.deferred_commits;
8868 }
8869 match &outcome {
8870 SyncOutcome::Ok(_) => self.reset_background_retry(),
8871 SyncOutcome::Failed { error_code, .. }
8872 if Self::retryable_transport_code(error_code) =>
8873 {
8874 self.schedule_background_retry();
8875 }
8876 SyncOutcome::Failed { .. } => {}
8877 }
8878 if meta.deferred_commits > 0 {
8879 self.set_sync_needed(true, true);
8882 }
8883 self.previous_version_lifetime_check();
8886 outcome
8887 }
8888
8889 pub fn sync_until_idle(
8890 &mut self,
8891 transport: &mut dyn Transport,
8892 max_rounds: Option<u32>,
8893 ) -> SyncOutcome {
8894 let rounds = max_rounds.unwrap_or(20).max(1);
8895 let mut aggregate = SyncReport::default();
8896 let mut spent = 0;
8897 while spent < rounds {
8898 spent += 1;
8899 let before = self
8900 .subs
8901 .iter()
8902 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
8903 .collect::<Vec<_>>();
8904 match self.sync(transport) {
8905 SyncOutcome::Failed {
8906 error_code,
8907 message,
8908 } => {
8909 return SyncOutcome::Failed {
8910 error_code,
8911 message,
8912 };
8913 }
8914 SyncOutcome::Ok(report) => {
8915 if max_rounds.is_none()
8916 && report.segment_rows_applied > 0
8917 && !report.bootstrapping.is_empty()
8918 && before
8919 != self
8920 .subs
8921 .iter()
8922 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
8923 .collect::<Vec<_>>()
8924 {
8925 spent = 0;
8926 }
8927 aggregate.pushed += report.pushed;
8928 aggregate.applied.extend(report.applied.iter().cloned());
8929 aggregate.rejected.extend(report.rejected.iter().cloned());
8930 aggregate.retryable.extend(report.retryable.iter().cloned());
8931 aggregate.conflicts += report.conflicts;
8932 aggregate.commits_applied += report.commits_applied;
8933 aggregate.segment_rows_applied += report.segment_rows_applied;
8934 aggregate.bootstrapping = report.bootstrapping.clone();
8935 aggregate.resets.extend(report.resets.iter().cloned());
8936 aggregate.revoked.extend(report.revoked.iter().cloned());
8937 aggregate.failed.extend(report.failed.iter().cloned());
8938 aggregate.deferred_commits = report.deferred_commits;
8939 if report.schema_floor.is_some() {
8940 aggregate.schema_floor = report.schema_floor.clone();
8941 }
8942 let more = !report.bootstrapping.is_empty()
8948 || report.commits_applied > 0
8949 || report.segment_rows_applied > 0
8950 || !report.resets.is_empty()
8951 || self.sync_needed;
8952 if !more {
8953 return SyncOutcome::Ok(aggregate);
8954 }
8955 }
8956 }
8957 }
8958 SyncOutcome::Failed {
8959 error_code: "sync.invalid_request".into(),
8960 message: "sync did not reach idle within the round budget".into(),
8961 }
8962 }
8963
8964 fn process_response(
8965 &mut self,
8966 transport: &mut dyn Transport,
8967 response: Message,
8968 meta: &RequestMeta,
8969 ) -> SyncOutcome {
8970 #[cfg(feature = "bench-internals")]
8971 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
8972 let mut report = SyncReport {
8973 pushed: meta.pushed_ids.len() as u32,
8974 ..SyncReport::default()
8975 };
8976 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
8977 HashMap::new();
8978 let pushed_ids = meta
8979 .pushed_ids
8980 .iter()
8981 .map(String::as_str)
8982 .collect::<HashSet<_>>();
8983 let mut last_final_push_result_id: Option<String> = None;
8984 for frame in &response.frames {
8985 match frame {
8986 Frame::PushResultDetails {
8987 client_commit_id,
8988 entries,
8989 } => {
8990 let details = rejection_details_by_commit
8991 .entry(client_commit_id.clone())
8992 .or_default();
8993 for entry in entries {
8994 let parsed = match RejectionDetails::parse(&entry.details.0) {
8995 Ok(value) => value,
8996 Err(message) => {
8997 return SyncOutcome::Failed {
8998 error_code: "sync.invalid_request".to_owned(),
8999 message,
9000 };
9001 }
9002 };
9003 details.insert(entry.op_index, parsed);
9004 }
9005 }
9006 Frame::PushResult {
9007 client_commit_id,
9008 status,
9009 results,
9010 ..
9011 } if pushed_ids.contains(client_commit_id.as_str())
9012 && Self::push_result_is_final(*status, results) =>
9013 {
9014 last_final_push_result_id = Some(client_commit_id.clone());
9015 }
9016 _ => {}
9017 }
9018 }
9019 let mut frames = response.frames.into_iter().peekable();
9020 if response.wire_version < 2 {
9021 return SyncOutcome::Failed {
9022 error_code: "client.invalid_host_response".to_owned(),
9023 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
9024 };
9025 }
9026 match frames.next() {
9027 Some(Frame::RespHeader {
9028 required_schema_version,
9029 latest_schema_version,
9030 log_epoch,
9031 reset_required,
9032 }) => {
9033 let Some(log_epoch) = log_epoch else {
9034 return SyncOutcome::Failed {
9035 error_code: "client.invalid_host_response".to_owned(),
9036 message: "response header omits logEpoch".to_owned(),
9037 };
9038 };
9039 let Some(reset_required) = reset_required else {
9040 return SyncOutcome::Failed {
9041 error_code: "client.invalid_host_response".to_owned(),
9042 message: "response header omits resetRequired".to_owned(),
9043 };
9044 };
9045 if let Some(required) = required_schema_version {
9046 let floor = SchemaFloor {
9049 required_schema_version: Some(required),
9050 latest_schema_version,
9051 };
9052 self.set_schema_floor(Some(floor.clone()));
9053 report.schema_floor = Some(floor);
9054 return SyncOutcome::Ok(report);
9055 }
9056 if reset_required {
9057 if frames.next().is_some() {
9058 return SyncOutcome::Failed {
9059 error_code: "client.invalid_host_response".to_owned(),
9060 message: "log-epoch reset response contains body frames".to_owned(),
9061 };
9062 }
9063 match self.run_log_epoch_reset(&log_epoch) {
9064 Ok(resets) => {
9065 report.resets = resets;
9066 return SyncOutcome::Ok(report);
9067 }
9068 Err(message) => {
9069 return SyncOutcome::Failed {
9070 error_code: "sync.local_corrupt".to_owned(),
9071 message,
9072 };
9073 }
9074 }
9075 }
9076 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
9077 return SyncOutcome::Failed {
9078 error_code: "client.invalid_host_response".to_owned(),
9079 message: "server changed logEpoch without requiring a reset".to_owned(),
9080 };
9081 }
9082 }
9083 _ => {
9084 return SyncOutcome::Failed {
9085 error_code: "sync.invalid_request".to_owned(),
9086 message: "response does not start with RESP_HEADER".to_owned(),
9087 };
9088 }
9089 }
9090
9091 let mut failure: Option<(String, String)> = None;
9092 while let Some(frame) = frames.next() {
9093 match frame {
9094 frame @ Frame::PushResult { .. } => {
9095 let successful = matches!(
9096 &frame,
9097 Frame::PushResult {
9098 status: PushStatus::Applied | PushStatus::Cached,
9099 ..
9100 }
9101 );
9102 let mut results = vec![frame];
9103 if successful {
9104 while matches!(
9105 frames.peek(),
9106 Some(Frame::PushResult {
9107 status: PushStatus::Applied | PushStatus::Cached,
9108 ..
9109 })
9110 ) {
9111 if let Some(next) = frames.next() {
9112 results.push(next);
9113 }
9114 }
9115 }
9116 if self
9117 .handle_push_results(
9118 &results,
9119 &pushed_ids,
9120 &rejection_details_by_commit,
9121 &mut report,
9122 last_final_push_result_id.as_deref(),
9123 )
9124 .is_err()
9125 {
9126 failure = Some((
9127 "client.outcome_persistence_failed".to_owned(),
9128 "local commit outcome could not be persisted".to_owned(),
9129 ));
9130 break;
9131 }
9132 }
9133 Frame::PushResultDetails { .. } => {}
9134 Frame::SubStart {
9135 id,
9136 status,
9137 reason_code,
9138 effective_scopes,
9139 bootstrap: _,
9140 } => {
9141 let mut body = Vec::new();
9142 let mut sub_end: Option<(i64, Option<String>)> = None;
9143 for inner in frames.by_ref() {
9144 match inner {
9145 Frame::SubEnd {
9146 next_cursor,
9147 bootstrap_state,
9148 } => {
9149 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
9150 break;
9151 }
9152 Frame::Unknown { .. } => {}
9153 other => body.push(other),
9154 }
9155 }
9156 if let Err(SectionError::Abort(code, message)) = self.process_section(
9157 transport,
9158 &id,
9159 status,
9160 &reason_code,
9161 effective_scopes,
9162 body,
9163 sub_end,
9164 meta,
9165 &mut report,
9166 ) {
9167 failure = Some((code, message));
9168 break;
9169 }
9170 }
9171 Frame::Lease {
9172 lease_id,
9173 expires_at_ms,
9174 } => {
9175 self.set_lease_state(Some(LeaseState {
9178 lease_id: Some(lease_id),
9179 expires_at_ms: Some(expires_at_ms),
9180 error_code: None,
9181 }));
9182 }
9183 Frame::Error { code, message, .. } => {
9184 failure = Some((code, message));
9187 break;
9188 }
9189 Frame::Unknown { .. } => {}
9190 _ => {
9191 failure = Some((
9192 "sync.invalid_request".to_owned(),
9193 "unexpected frame in response".to_owned(),
9194 ));
9195 break;
9196 }
9197 }
9198 }
9199
9200 if self.overlay_dirty.get() {
9205 self.rebuild_overlay();
9206 }
9207 if let Some((error_code, message)) = failure {
9208 return SyncOutcome::Failed {
9209 error_code,
9210 message,
9211 };
9212 }
9213 if let Err(message) = self.drain_pending_evictions() {
9216 return SyncOutcome::Failed {
9217 error_code: "storage.failed".into(),
9218 message,
9219 };
9220 }
9221 self.ack_after_pull(transport);
9222 if self.upgrading && report.bootstrapping.is_empty() {
9225 self.set_upgrading(false);
9226 }
9227 SyncOutcome::Ok(report)
9228 }
9229
9230 fn handle_push_results(
9233 &mut self,
9234 frames: &[Frame],
9235 pushed_ids: &HashSet<&str>,
9236 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
9237 report: &mut SyncReport,
9238 last_final_push_result_id: Option<&str>,
9239 ) -> Result<(), String> {
9240 self.begin_observation("syncular_push_results")?;
9241 let conflict_count = self.conflicts.len();
9242 let rejection_count = self.rejections.len();
9243 let applied_count = report.applied.len();
9244 let rejected_count = report.rejected.len();
9245 let retryable_count = report.retryable.len();
9246 let was_dirty = self.overlay_dirty.get();
9247 let mut removed = Vec::new();
9248 let mut batch = ChangeAccumulator::default();
9249 let persisted = (|| {
9250 for frame in frames {
9251 let Frame::PushResult {
9252 client_commit_id,
9253 status,
9254 results,
9255 ..
9256 } = frame
9257 else {
9258 return Err("unexpected acknowledgement frame".to_owned());
9259 };
9260 if !pushed_ids.contains(client_commit_id.as_str()) {
9261 continue;
9262 }
9263 let Some(index) = self
9264 .outbox
9265 .iter()
9266 .position(|commit| commit.client_commit_id == *client_commit_id)
9267 else {
9268 continue;
9269 };
9270 let status = *status;
9271 let rejection_details = rejection_details_by_commit.get(client_commit_id);
9272 let operations = self.outbox[index].ops.clone();
9273 match status {
9274 PushStatus::Applied | PushStatus::Cached => {
9275 let journal_results = results
9278 .iter()
9279 .map(|result| {
9280 let op_index = match result {
9281 OpResult::Applied { op_index }
9282 | OpResult::Conflict { op_index, .. }
9283 | OpResult::Error { op_index, .. } => *op_index,
9284 };
9285 CommitOperationOutcome::Applied { op_index }
9286 })
9287 .collect::<Vec<_>>();
9288 let outcome_status = if status == PushStatus::Applied {
9289 CommitOutcomeStatus::Applied
9290 } else {
9291 CommitOutcomeStatus::Cached
9292 };
9293 let persisted = self
9294 .persist_commit_outcome(
9295 client_commit_id,
9296 outcome_status,
9297 &journal_results,
9298 None,
9299 )
9300 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9301 persisted?;
9302 self.overlay_dirty.set(true);
9303 batch.status = true;
9304 batch.outcomes = true;
9305 }
9306 PushStatus::Rejected => {
9307 if results.iter().any(|result| {
9308 matches!(
9309 result,
9310 OpResult::Error {
9311 code,
9312 retryable: true,
9313 ..
9314 } if code == "sync.idempotency_cache_miss"
9315 )
9316 }) {
9317 report.retryable.push(client_commit_id.to_owned());
9320 continue;
9321 }
9322
9323 let mut journal_results = Vec::with_capacity(results.len());
9324 let mut conflicts = Vec::new();
9325 let mut rejections = Vec::new();
9326 for result in results {
9327 match result {
9328 OpResult::Applied { op_index } => {
9329 journal_results.push(CommitOperationOutcome::Applied {
9330 op_index: *op_index,
9331 });
9332 }
9333 OpResult::Conflict {
9334 op_index,
9335 code,
9336 message,
9337 server_version,
9338 server_row,
9339 conflict_columns: conflict_columns_bitmap,
9340 } => {
9341 let operation = operations
9342 .get(*op_index as usize)
9343 .map(CommitOperation::from);
9344 let (table, row_id) = operation
9345 .as_ref()
9346 .map(|op| (op.table.clone(), op.row_id.clone()))
9347 .unwrap_or_default();
9348 let server_row_json = self
9349 .schema
9350 .table(&table)
9351 .and_then(|t| {
9352 decode_row_bytes(t, server_row, &self.encryption)
9353 .ok()
9354 .map(|row| (t, row))
9355 })
9356 .map(|(t, row)| {
9357 let mut map = Map::new();
9358 for (i, column) in t.columns.iter().enumerate() {
9359 map.insert(
9360 column.name.clone(),
9361 column_value_to_json(
9362 row.get(i).unwrap_or(&None),
9363 ),
9364 );
9365 }
9366 map
9367 })
9368 .unwrap_or_default();
9369 let conflict_columns = self
9372 .schema
9373 .table(&table)
9374 .map(|t| {
9375 t.columns
9376 .iter()
9377 .enumerate()
9378 .filter(|(index, _)| {
9379 conflict_columns_bitmap
9380 .get(index / 8)
9381 .is_some_and(|byte| {
9382 byte & (1 << (index % 8)) != 0
9383 })
9384 })
9385 .map(|(_, column)| column.name.clone())
9386 .collect::<Vec<_>>()
9387 })
9388 .unwrap_or_default();
9389 let conflict = ConflictRecord {
9390 client_commit_id: client_commit_id.to_owned(),
9391 op_index: *op_index,
9392 table,
9393 row_id,
9394 code: code.clone(),
9395 message: message.clone(),
9396 server_version: *server_version,
9397 server_row: server_row_json,
9398 conflict_columns,
9399 operation,
9400 };
9401 journal_results.push(CommitOperationOutcome::Conflict {
9402 conflict: conflict.clone(),
9403 });
9404 conflicts.push(conflict);
9405 }
9406 OpResult::Error {
9407 op_index,
9408 code,
9409 message,
9410 retryable,
9411 } => {
9412 let rejection = RejectionRecord {
9413 client_commit_id: client_commit_id.to_owned(),
9414 op_index: *op_index,
9415 code: code.clone(),
9416 message: message.clone(),
9417 retryable: *retryable,
9418 details: rejection_details
9419 .and_then(|details| details.get(op_index))
9420 .cloned(),
9421 operation: operations
9422 .get(*op_index as usize)
9423 .map(CommitOperation::from),
9424 };
9425 journal_results.push(CommitOperationOutcome::Error {
9426 rejection: rejection.clone(),
9427 });
9428 rejections.push(rejection);
9429 }
9430 }
9431 }
9432 let outcome_status = if conflicts.is_empty() {
9433 CommitOutcomeStatus::Rejected
9434 } else {
9435 CommitOutcomeStatus::Conflict
9436 };
9437 let persisted = self
9438 .persist_commit_outcome(
9439 client_commit_id,
9440 outcome_status,
9441 &journal_results,
9442 Some(&operations),
9443 )
9444 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9445 persisted?;
9446 batch.conflicts = !conflicts.is_empty();
9447 batch.rejections = !rejections.is_empty();
9448 batch.status = true;
9449 batch.outcomes = true;
9450 self.conflicts.extend(conflicts);
9451 self.rejections.extend(rejections);
9452 self.overlay_dirty.set(true);
9453 }
9454 }
9455 removed.push((index, self.outbox.remove(index)));
9456 if batch.status {
9457 for operation in &operations {
9458 if !self.record_row_scopes(
9459 &mut batch,
9460 &operation.table,
9461 &operation.row_id,
9462 false,
9463 ) {
9464 batch.table(&operation.table);
9465 }
9466 }
9467 }
9468 match status {
9469 PushStatus::Applied | PushStatus::Cached => {
9470 report.applied.push(client_commit_id.to_owned())
9471 }
9472 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
9473 }
9474 }
9475 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)) {
9476 self.prune_commit_outcomes()?;
9477 }
9478 self.finish_observation("syncular_push_results", batch)
9479 })();
9480 if let Err(error) = persisted {
9481 self.rollback_observation("syncular_push_results");
9482 for (index, commit) in removed.into_iter().rev() {
9483 self.outbox.insert(index, commit);
9484 }
9485 self.conflicts.truncate(conflict_count);
9486 self.rejections.truncate(rejection_count);
9487 self.overlay_dirty.set(was_dirty);
9488 report.applied.truncate(applied_count);
9489 report.rejected.truncate(rejected_count);
9490 report.retryable.truncate(retryable_count);
9491 return Err(error);
9492 }
9493 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
9494 Ok(())
9495 }
9496
9497 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
9498 status != PushStatus::Rejected
9499 || !results.iter().any(|result| {
9500 matches!(
9501 result,
9502 OpResult::Error {
9503 code,
9504 retryable: true,
9505 ..
9506 } if code == "sync.idempotency_cache_miss"
9507 )
9508 })
9509 }
9510
9511 #[allow(clippy::too_many_arguments)]
9514 fn process_section(
9515 &mut self,
9516 transport: &mut dyn Transport,
9517 id: &str,
9518 status: SubStatus,
9519 reason_code: &str,
9520 effective_scopes: Vec<(String, Vec<String>)>,
9521 body: Vec<Frame>,
9522 sub_end: Option<(i64, Option<String>)>,
9523 meta: &RequestMeta,
9524 report: &mut SyncReport,
9525 ) -> Result<(), SectionError> {
9526 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
9527 return Ok(()); };
9529 if status != SubStatus::Active && sub_end.is_none() {
9530 return Err(SectionError::Abort(
9531 "sync.invalid_request".into(),
9532 "subscription section without SUB_END".into(),
9533 ));
9534 }
9535 match status {
9536 SubStatus::Revoked => {
9537 self.begin_observation("syncular_revocation")
9538 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9539 let previous = self.subs[sub_index].clone();
9540 let was_dirty = self.overlay_dirty.get();
9541 let previous_outbox = self.outbox.clone();
9542 let rejection_count = self.rejections.len();
9543 let revoked_count = report.revoked.len();
9544 let failed_count = report.failed.len();
9545 let result =
9546 (|| {
9547 let mut batch = ChangeAccumulator::default();
9548 let registered = self.window_unit_by_sub_id(id);
9549 let (table, effective) = {
9551 let sub = &self.subs[sub_index];
9552 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9553 };
9554 let purged = self.purge_scope_rows(&table, &effective);
9555 match purged {
9556 Ok(()) => {
9557 self.record_scope_map(&mut batch, &table, &effective);
9558 let sub = &mut self.subs[sub_index];
9559 sub.state = SubState::Revoked;
9560 sub.reason_code = Some(if reason_code.is_empty() {
9561 "sync.scope_revoked".to_owned()
9562 } else {
9563 reason_code.to_owned()
9564 });
9565 report.revoked.push(id.to_owned());
9566 let doomed_effective = effective;
9567 let sub_table = table;
9568 self.persist_sub(&self.subs[sub_index].clone()).map_err(
9569 |message| SectionError::Abort("storage.failed".into(), message),
9570 )?;
9571 let dropped = self
9572 .drop_doomed_outbox(&sub_table, &doomed_effective)
9573 .map_err(|message| {
9574 SectionError::Abort("storage.failed".to_owned(), message)
9575 })?;
9576 if dropped {
9577 batch.status = true;
9578 batch.rejections = true;
9579 batch.outcomes = true;
9580 }
9581 self.delete_unreferenced_cached_blobs();
9584 }
9585 Err(()) => {
9586 let sub = &mut self.subs[sub_index];
9589 sub.state = SubState::Failed;
9590 sub.reason_code = Some("sync.scope_revoked".to_owned());
9591 report.failed.push(id.to_owned());
9592 self.persist_sub(&self.subs[sub_index].clone()).map_err(
9593 |message| SectionError::Abort("storage.failed".into(), message),
9594 )?;
9595 }
9596 }
9597 if let Some((base_key, unit)) = registered {
9598 batch.window(&base_key, &self.subs[sub_index].table, &unit);
9599 }
9600 self.rebuild_overlay_if_dirty();
9601 self.finish_observation("syncular_revocation", batch)
9602 .map_err(|message| {
9603 SectionError::Abort("storage.failed".to_owned(), message)
9604 })?;
9605 Ok(())
9606 })();
9607 if result.is_err() {
9608 self.rollback_observation("syncular_revocation");
9609 self.subs[sub_index] = previous;
9610 self.overlay_dirty.set(was_dirty);
9611 self.outbox = previous_outbox;
9612 self.rejections.truncate(rejection_count);
9613 report.revoked.truncate(revoked_count);
9614 report.failed.truncate(failed_count);
9615 }
9616 result
9617 }
9618 SubStatus::Reset => {
9619 self.begin_observation("syncular_reset")
9620 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9621 let previous = self.subs[sub_index].clone();
9622 let was_dirty = self.overlay_dirty.get();
9623 let reset_count = report.resets.len();
9624 let result = (|| {
9625 let mut batch = ChangeAccumulator::default();
9626 let registered = self.window_unit_by_sub_id(id);
9627 let sub = &mut self.subs[sub_index];
9630 sub.cursor = -1;
9631 sub.bootstrap_state = None;
9632 report.resets.push(id.to_owned());
9633 self.persist_sub(&self.subs[sub_index].clone())
9634 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9635 if let Some((base_key, unit)) = registered {
9636 batch.window(&base_key, &self.subs[sub_index].table, &unit);
9637 }
9638 self.finish_observation("syncular_reset", batch)
9639 .map_err(|message| {
9640 SectionError::Abort("storage.failed".to_owned(), message)
9641 })?;
9642 Ok(())
9643 })();
9644 if result.is_err() {
9645 self.rollback_observation("syncular_reset");
9646 self.subs[sub_index] = previous;
9647 self.overlay_dirty.set(was_dirty);
9648 report.resets.truncate(reset_count);
9649 }
9650 result
9651 }
9652 SubStatus::Active => {
9653 let fresh = meta
9654 .fresh
9655 .iter()
9656 .find(|(fid, _)| fid == id)
9657 .map(|(_, fresh)| *fresh)
9658 .unwrap_or(false);
9659 let previous = self.subs[sub_index].clone();
9660 self.subs[sub_index].effective = Some(effective_scopes);
9661 let outcome = self
9662 .apply_section_body(transport, sub_index, body, fresh, meta, report)
9663 .and_then(|()| {
9664 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
9665 SectionError::Abort(
9666 "sync.invalid_request".into(),
9667 "subscription section without SUB_END".into(),
9668 )
9669 })?;
9670 self.apply_sub_end(sub_index, cursor, bootstrap_state)
9671 .map_err(|message| {
9672 SectionError::Abort("storage.failed".into(), message)
9673 })
9674 });
9675 match outcome {
9676 Ok(()) => {
9677 if self.subs[sub_index].bootstrap_state.is_some() {
9678 report.bootstrapping.push(id.to_owned());
9679 }
9680 Ok(())
9681 }
9682 Err(SectionError::FailClosed) => {
9683 self.subs[sub_index] = previous.clone();
9684 self.begin_observation("syncular_section_failure")
9685 .map_err(|message| {
9686 SectionError::Abort("storage.failed".into(), message)
9687 })?;
9688 let persisted = (|| {
9689 let mut batch = ChangeAccumulator::default();
9690 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
9691 batch.window(&base_key, &self.subs[sub_index].table, &unit);
9692 }
9693 self.subs[sub_index].state = SubState::Failed;
9694 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
9695 self.persist_sub(&self.subs[sub_index])?;
9696 self.finish_observation("syncular_section_failure", batch)
9697 })();
9698 if let Err(message) = persisted {
9699 self.rollback_observation("syncular_section_failure");
9700 self.subs[sub_index] = previous;
9701 return Err(SectionError::Abort("storage.failed".into(), message));
9702 }
9703 report.failed.push(id.to_owned());
9704 Ok(())
9705 }
9706 Err(error) => {
9707 self.subs[sub_index] = previous;
9710 Err(error)
9711 }
9712 }
9713 }
9714 }
9715 }
9716
9717 fn apply_sub_end(
9718 &mut self,
9719 sub_index: usize,
9720 cursor: i64,
9721 bootstrap_state: Option<String>,
9722 ) -> Result<(), String> {
9723 #[cfg(feature = "bench-internals")]
9724 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
9725 let previous = self.subs[sub_index].clone();
9726 self.begin_observation("syncular_sub_end")?;
9727 let persisted = (|| {
9728 let mut batch = ChangeAccumulator::default();
9729 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
9730 && cursor >= 0
9731 && bootstrap_state.is_none();
9732 if completed {
9733 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
9734 batch.window(&base_key, &previous.table, &unit);
9735 }
9736 }
9737 let sub = &mut self.subs[sub_index];
9738 sub.cursor = cursor;
9739 sub.bootstrap_state = bootstrap_state;
9740 sub.synced_once = true;
9741 self.persist_sub(&self.subs[sub_index])?;
9742 self.finish_observation("syncular_sub_end", batch)
9743 })();
9744 if persisted.is_err() {
9745 self.rollback_observation("syncular_sub_end");
9746 self.subs[sub_index] = previous;
9747 }
9748 persisted
9749 }
9750
9751 fn apply_section_body(
9752 &mut self,
9753 transport: &mut dyn Transport,
9754 sub_index: usize,
9755 body: Vec<Frame>,
9756 fresh: bool,
9757 meta: &RequestMeta,
9758 report: &mut SyncReport,
9759 ) -> Result<(), SectionError> {
9760 let mut saw_segment = false;
9761 for frame in body {
9762 match frame {
9763 Frame::Commit {
9764 tables, changes, ..
9765 } => {
9766 self.apply_commit_frame(&tables, &changes)
9767 .map_err(|(c, m)| SectionError::Abort(c, m))?;
9768 report.commits_applied += 1;
9769 }
9770 Frame::SegmentInline { payload } => {
9771 let segment = decode_rows_segment(&payload)
9772 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
9773 self.progress.update(|p| {
9774 p.phase = ProgressPhase::Import;
9775 p.subscription_id = Some(self.subs[sub_index].id.clone());
9776 p.table = Some(self.subs[sub_index].table.clone());
9777 p.segment_id = None;
9778 p.bytes_received = payload.len() as u64;
9779 p.bytes_total = Some(payload.len() as u64);
9780 p.rows_processed = 0;
9781 p.rows_total =
9782 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
9783 });
9784 let first = !saw_segment;
9785 saw_segment = true;
9786 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9787 report.segment_rows_applied += applied;
9788 }
9789 Frame::SegmentRef {
9790 segment_id,
9791 byte_length,
9792 media_type,
9793 table,
9794 row_count,
9795 as_of_commit_seq,
9796 scope_digest,
9797 row_cursor,
9798 next_row_cursor,
9799 url,
9800 url_expires_at_ms,
9801 ..
9802 } => {
9803 let advertised = match media_type {
9806 MediaType::Rows => {
9807 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
9808 || meta.accept & ACCEPT_INLINE_ROWS != 0
9809 }
9810 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
9811 };
9812 if !advertised {
9813 return Err(SectionError::Abort(
9814 "sync.invalid_request".to_owned(),
9815 format!(
9816 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
9817 media_type.name()
9818 ),
9819 ));
9820 }
9821 self.progress.update(|p| {
9822 p.phase = ProgressPhase::Download;
9823 p.subscription_id = Some(self.subs[sub_index].id.clone());
9824 p.table = Some(table.clone());
9825 p.segment_id = Some(segment_id.clone());
9826 p.bytes_received = 0;
9827 p.bytes_total = u64::try_from(byte_length).ok();
9828 p.rows_processed = 0;
9829 p.rows_total = u64::try_from(row_count).ok();
9830 });
9831 let observer = self.progress.clone();
9832 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
9833 let bytes = if let Some(url) = url {
9834 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
9839 return Err(SectionError::Abort(
9840 "sync.invalid_request".to_owned(),
9841 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
9842 .to_owned(),
9843 ));
9844 }
9845 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
9847 return Err(SectionError::Abort(
9848 "sync.segment_expired".to_owned(),
9849 format!(
9850 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
9851 ),
9852 ));
9853 }
9854 transport
9855 .fetch_url(&url, &mut on_progress)
9856 .map_err(|e| SectionError::Abort(e.code, e.message))?
9857 } else {
9858 let requested_scopes_json =
9859 canonical_scope_json(&self.subs[sub_index].requested);
9860 transport
9861 .download_segment(
9862 &SegmentRequest {
9863 segment_id: segment_id.clone(),
9864 table,
9865 requested_scopes_json,
9866 },
9867 &mut on_progress,
9868 )
9869 .map_err(|e| SectionError::Abort(e.code, e.message))?
9870 };
9871 on_progress(bytes.len() as u64);
9873 let digest = Sha256::digest(&bytes);
9874 let expected = segment_id
9875 .strip_prefix("sha256:")
9876 .unwrap_or(segment_id.as_str());
9877 if bytes_to_hex(&digest) != expected {
9878 return Err(SectionError::Abort(
9879 "sync.invalid_request".to_owned(),
9880 "segment bytes do not match the content address (§5.1)".to_owned(),
9881 ));
9882 }
9883 self.progress.update(|p| p.phase = ProgressPhase::Import);
9884 if media_type == MediaType::Sqlite {
9885 if row_cursor.is_some() || next_row_cursor.is_some() {
9888 return Err(SectionError::Abort(
9889 "sync.invalid_request".to_owned(),
9890 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
9891 .to_owned(),
9892 ));
9893 }
9894 let first = !saw_segment;
9895 saw_segment = true;
9896 let applied = self.apply_sqlite_segment(
9897 sub_index,
9898 &bytes,
9899 fresh && first,
9900 row_count,
9901 as_of_commit_seq,
9902 &scope_digest,
9903 )?;
9904 report.segment_rows_applied += applied;
9905 } else {
9906 let segment = decode_rows_segment(&bytes).map_err(|e| {
9907 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
9908 })?;
9909 let first = row_cursor.is_none();
9910 saw_segment = true;
9911 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9912 report.segment_rows_applied += applied;
9913 }
9914 }
9915 Frame::Unknown { .. } => {}
9916 Frame::Error { code, message, .. } => {
9917 return Err(SectionError::Abort(code, message))
9918 }
9919 _ => {
9920 return Err(SectionError::Abort(
9921 "sync.invalid_request".to_owned(),
9922 "unexpected frame inside a subscription section".to_owned(),
9923 ));
9924 }
9925 }
9926 }
9927 Ok(())
9928 }
9929
9930 fn apply_commit_frame(
9932 &mut self,
9933 tables: &[String],
9934 changes: &[ssp2::model::Change],
9935 ) -> Result<(), (String, String)> {
9936 #[cfg(feature = "bench-internals")]
9937 let _phase = self.benchmark_phases.start(Phase::CommitApply);
9938 let was_dirty = self.overlay_dirty.get();
9939 self.begin_observation("syncular_commit")
9940 .map_err(|message| ("storage.failed".into(), message))?;
9941 let applied = (|| {
9942 let mut batch = ChangeAccumulator::default();
9943 self.record_commit_changes(&mut batch, tables, changes);
9944 self.apply_commit_changes(tables, changes)?;
9945 self.rebuild_overlay_if_dirty();
9946 self.finish_observation("syncular_commit", batch)
9947 .map_err(|message| ("storage.failed".into(), message))
9948 })();
9949 if applied.is_err() {
9950 self.rollback_observation("syncular_commit");
9951 self.overlay_dirty.set(was_dirty);
9952 }
9953 applied
9954 }
9955
9956 fn apply_commit_changes(
9957 &mut self,
9958 tables: &[String],
9959 changes: &[ssp2::model::Change],
9960 ) -> Result<(), (String, String)> {
9961 let mirror_visible = !self.overlay_dirty.get()
9966 && (self.outbox.is_empty()
9967 || changes.iter().all(|change| {
9968 tables
9969 .get(change.table_index as usize)
9970 .and_then(|name| self.schema.table(name))
9971 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
9972 }));
9973 for change in changes {
9974 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
9975 (
9976 "sync.invalid_request".to_owned(),
9977 "change tableIndex out of range".to_owned(),
9978 )
9979 })?;
9980 let table = self.schema.table(table_name).ok_or_else(|| {
9981 (
9982 "sync.schema_mismatch".to_owned(),
9983 format!("change targets unknown table {table_name:?}"),
9984 )
9985 })?;
9986 match change.op {
9987 Op::Upsert => {
9988 let payload = change.row.as_ref().ok_or_else(|| {
9989 (
9990 "sync.invalid_request".to_owned(),
9991 "upsert change without row payload".to_owned(),
9992 )
9993 })?;
9994 let row = {
9996 #[cfg(feature = "bench-internals")]
9997 let _phase = self.benchmark_phases.start(Phase::RowDecode);
9998 decode_row_bytes(table, payload, &self.encryption)
9999 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
10000 };
10001 let version = change.row_version.unwrap_or(0);
10002 let table_name = table.name.clone();
10003 self.write_base_row(&table_name, &row, version)
10004 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10005 if mirror_visible {
10006 self.write_row(&visible_table(&table_name), &table_name, &row, version)
10007 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10008 }
10009 }
10010 Op::Delete => {
10011 self.delete_base_row(table_name, &change.row_id)
10012 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10013 if mirror_visible {
10014 let sql = format!(
10015 "DELETE FROM {} WHERE {}",
10016 visible_table(table_name),
10017 row_id_predicate(table)
10018 );
10019 self.conn
10020 .prepare_cached(&sql)
10021 .and_then(|mut statement| {
10022 statement.execute(rusqlite::params![change.row_id])
10023 })
10024 .map_err(|error| {
10025 ("sync.invalid_request".to_owned(), error.to_string())
10026 })?;
10027 }
10028 }
10029 }
10030 }
10031 if mirror_visible {
10032 if !self.outbox.is_empty() {
10033 let changed_rows: HashSet<(&str, &str)> = changes
10034 .iter()
10035 .map(|change| {
10036 (
10037 tables[change.table_index as usize].as_str(),
10038 change.row_id.as_str(),
10039 )
10040 })
10041 .collect();
10042 self.apply_outbox_ops(
10043 self.outbox
10044 .iter()
10045 .flat_map(|commit| &commit.ops)
10046 .filter(|op| {
10047 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
10048 }),
10049 );
10050 }
10051 self.overlay_dirty.set(false);
10052 }
10053 Ok(())
10054 }
10055
10056 fn apply_segment(
10061 &mut self,
10062 sub_index: usize,
10063 segment: &RowsSegment,
10064 first_fresh_page: bool,
10065 ) -> Result<u32, SectionError> {
10066 let (sub_table, effective) = {
10067 let sub = &self.subs[sub_index];
10068 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10069 };
10070 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10071 SectionError::Abort(
10072 "sync.schema_mismatch".to_owned(),
10073 format!("subscription table {sub_table:?} is not in the client schema"),
10074 )
10075 })?;
10076 let matches = segment.table == table.name
10081 && segment.schema_version == self.schema.version
10082 && segment.columns.len() == table.wire_columns.len()
10083 && segment
10084 .columns
10085 .iter()
10086 .zip(table.wire_columns.iter())
10087 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
10088 if !matches {
10089 return Err(SectionError::Abort(
10090 "sync.schema_mismatch".to_owned(),
10091 "segment column table does not match the generated schema (§5.2)".to_owned(),
10092 ));
10093 }
10094 let mut applied = 0u32;
10095 let empty = Vec::new();
10096 let blocks = if segment.blocks.is_empty() {
10097 std::slice::from_ref(&empty)
10098 } else {
10099 &segment.blocks
10100 };
10101 for (index, block) in blocks.iter().enumerate() {
10102 let clear = first_fresh_page && index == 0;
10103 if !clear && block.is_empty() {
10104 continue;
10105 }
10106 let was_dirty = self.overlay_dirty.get();
10107 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10108 && self
10109 .outbox
10110 .iter()
10111 .flat_map(|commit| &commit.ops)
10112 .any(|op| op.table == table.name);
10113 let mirror_visible = !reconcile_pending;
10114 self.begin_observation("syncular_segment_block")
10115 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10116 let outcome = (|| {
10117 self.rebuild_overlay_if_dirty();
10118 let mut changed_keys = Vec::new();
10119 let mut batch = ChangeAccumulator::default();
10120 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
10121 batch.table(&table.name);
10122 }
10123 if clear {
10124 self.purge_scope_rows(&table.name, &effective)
10125 .map_err(|()| SectionError::FailClosed)?;
10126 }
10127 for (row_index, row) in block.iter().enumerate() {
10128 let decrypted;
10129 let values = if table.has_encrypted_columns() {
10130 let mut values = row.values.clone();
10131 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
10132 .map_err(|message| {
10133 SectionError::Abort("client.decrypt_failed".into(), message)
10134 })?;
10135 decrypted = values;
10136 &decrypted
10137 } else {
10138 &row.values
10139 };
10140 self.write_base_row(&table.name, values, row.server_version)
10141 .map_err(|message| {
10142 SectionError::Abort("sync.invalid_request".into(), message)
10143 })?;
10144 if reconcile_pending {
10145 changed_keys.push(
10146 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
10147 |message| SectionError::Abort("storage.failed".into(), message),
10148 )?,
10149 );
10150 }
10151 if mirror_visible {
10152 self.write_row(
10153 &visible_table(&table.name),
10154 &table.name,
10155 values,
10156 row.server_version,
10157 )
10158 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10159 }
10160 let processed = u64::from(applied) + row_index as u64 + 1;
10161 if processed.is_multiple_of(1024) {
10162 self.progress.update(|p| p.rows_processed = processed);
10163 }
10164 }
10165 if reconcile_pending {
10166 self.reconcile_imported_rows(&table, &mut changed_keys)
10167 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10168 }
10169 self.apply_outbox_ops(
10170 self.outbox
10171 .iter()
10172 .flat_map(|commit| &commit.ops)
10173 .filter(|op| op.table == table.name),
10174 );
10175 self.overlay_dirty.set(false);
10176 self.finish_observation("syncular_segment_block", batch)
10177 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
10178 })();
10179 if let Err(error) = outcome {
10180 self.rollback_observation("syncular_segment_block");
10181 self.overlay_dirty.set(was_dirty);
10182 return Err(error);
10183 }
10184 applied += block.len() as u32;
10185 self.progress
10186 .update(|p| p.rows_processed = u64::from(applied));
10187 std::thread::yield_now();
10188 }
10189 self.progress
10190 .update(|p| p.rows_processed = u64::from(applied));
10191 Ok(applied)
10192 }
10193
10194 fn apply_sqlite_segment(
10202 &mut self,
10203 sub_index: usize,
10204 bytes: &[u8],
10205 first_fresh_page: bool,
10206 row_count: i64,
10207 as_of_commit_seq: i64,
10208 scope_digest: &str,
10209 ) -> Result<u32, SectionError> {
10210 let invalid = |detail: &str| {
10211 SectionError::Abort(
10212 "sync.invalid_request".to_owned(),
10213 format!("sqlite segment rejected: {detail} (§5.3)"),
10214 )
10215 };
10216 let (sub_table, effective) = {
10217 let sub = &self.subs[sub_index];
10218 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10219 };
10220 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10221 SectionError::Abort(
10222 "sync.schema_mismatch".to_owned(),
10223 format!("subscription table {sub_table:?} is not in the client schema"),
10224 )
10225 })?;
10226
10227 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
10228 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
10229 let img = match rusqlite::Connection::open_with_flags(
10230 &path,
10231 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
10232 ) {
10233 Ok(conn) => conn,
10234 Err(_) => {
10235 let _ = std::fs::remove_file(&path);
10236 return Err(invalid("bytes do not open as a SQLite database"));
10237 }
10238 };
10239 let outcome = self.apply_sqlite_image(
10240 &img,
10241 &table,
10242 first_fresh_page,
10243 &effective,
10244 row_count,
10245 as_of_commit_seq,
10246 scope_digest,
10247 );
10248 drop(img);
10249 let _ = std::fs::remove_file(&path);
10250 outcome
10251 }
10252
10253 #[allow(clippy::too_many_arguments)]
10254 fn apply_sqlite_image(
10255 &mut self,
10256 img: &rusqlite::Connection,
10257 table: &crate::schema::TableSchema,
10258 first_fresh_page: bool,
10259 effective: &[(String, Vec<String>)],
10260 row_count: i64,
10261 as_of_commit_seq: i64,
10262 scope_digest: &str,
10263 ) -> Result<u32, SectionError> {
10264 let invalid = |detail: String| {
10265 SectionError::Abort(
10266 "sync.invalid_request".to_owned(),
10267 format!("sqlite segment rejected: {detail} (§5.3)"),
10268 )
10269 };
10270
10271 type MetaRow = (i64, String, i64, i64, String, i64, i64);
10274 let meta: MetaRow = img
10275 .query_row(
10276 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
10277 \"scopeDigest\", \"rowCount\",
10278 (SELECT count(*) FROM _syncular_segment)
10279 FROM _syncular_segment",
10280 [],
10281 |row| {
10282 Ok((
10283 row.get(0)?,
10284 row.get(1)?,
10285 row.get(2)?,
10286 row.get(3)?,
10287 row.get(4)?,
10288 row.get(5)?,
10289 row.get(6)?,
10290 ))
10291 },
10292 )
10293 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
10294 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
10295 if meta_count != 1 {
10296 return Err(invalid(format!(
10297 "_syncular_segment must contain exactly one row, found {meta_count}"
10298 )));
10299 }
10300 if format != 1 {
10301 return Err(invalid(format!("format {format}")));
10302 }
10303 if meta_table != table.name {
10304 return Err(invalid(format!("image table {meta_table:?}")));
10305 }
10306 if schema_version != i64::from(self.schema.version) {
10307 return Err(invalid(format!("schemaVersion {schema_version}")));
10308 }
10309 if pin != as_of_commit_seq {
10310 return Err(invalid(format!("asOfCommitSeq {pin}")));
10311 }
10312 if digest != scope_digest {
10313 return Err(invalid("scopeDigest mismatch".to_owned()));
10314 }
10315 if meta_rows != row_count {
10316 return Err(invalid(format!("rowCount {meta_rows}")));
10317 }
10318
10319 let mut names: Vec<String> = Vec::new();
10321 let mut primary_keys: Vec<String> = Vec::new();
10322 {
10323 let mut stmt = img
10324 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
10325 .map_err(|_| invalid("image data table missing".to_owned()))?;
10326 let mut rows = stmt
10327 .query([])
10328 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
10329 while let Some(row) = rows
10330 .next()
10331 .map_err(|_| invalid("image data table unreadable".to_owned()))?
10332 {
10333 if row.get::<_, i64>(5).map_err(|e| invalid(e.to_string()))? > 0 {
10334 primary_keys.push(row.get(1).map_err(|e| invalid(e.to_string()))?);
10335 }
10336 names.push(
10337 row.get::<_, String>(1)
10338 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
10339 );
10340 }
10341 }
10342 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
10343 expected.push("_syncular_version");
10344 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
10345 return Err(SectionError::Abort(
10346 "sync.schema_mismatch".to_owned(),
10347 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
10348 ));
10349 }
10350
10351 if primary_keys != vec![table.primary_key.clone()] {
10352 return Err(invalid(
10353 "image primary key does not match generated schema".into(),
10354 ));
10355 }
10356 let actual_count: i64 = img
10357 .query_row(
10358 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
10359 [],
10360 |row| row.get(0),
10361 )
10362 .map_err(|e| invalid(e.to_string()))?;
10363 if actual_count != row_count {
10364 return Err(invalid("image row count does not match descriptor".into()));
10365 }
10366 let insert = self.insert_row_sql(&base_table(&table.name), table);
10367 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
10368 let column_list = names
10369 .iter()
10370 .map(|n| quote_ident(n))
10371 .collect::<Vec<_>>()
10372 .join(", ");
10373 let mut stmt = img
10374 .prepare(&format!(
10375 "SELECT {column_list} FROM {} ORDER BY {}",
10376 quote_ident(&table.name),
10377 quote_ident(&table.primary_key)
10378 ))
10379 .map_err(|e| invalid(e.to_string()))?;
10380 let mut rows = stmt.query([]).map_err(|e| invalid(e.to_string()))?;
10381 let mut applied = 0u32;
10382 loop {
10383 let clear = applied == 0 && first_fresh_page;
10384 let was_dirty = self.overlay_dirty.get();
10385 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10386 && self
10387 .outbox
10388 .iter()
10389 .flat_map(|commit| &commit.ops)
10390 .any(|op| op.table == table.name);
10391 let mirror_visible = !reconcile_pending;
10392 self.begin_observation("syncular_image_chunk")
10393 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10394 let result = (|| {
10395 self.rebuild_overlay_if_dirty();
10396 let mut changed_keys = Vec::new();
10397 let mut batch = ChangeAccumulator::default();
10398 if clear {
10399 if self.scoped_rows_exist(&table.name, effective) {
10400 batch.table(&table.name);
10401 }
10402 self.purge_scope_rows(&table.name, effective)
10403 .map_err(|()| SectionError::FailClosed)?;
10404 }
10405 let mut processed = 0u32;
10406 {
10407 let mut ins = self
10408 .conn
10409 .prepare_cached(&insert)
10410 .map_err(|e| invalid(e.to_string()))?;
10411 let mut mirror = if mirror_visible {
10412 Some(
10413 self.conn
10414 .prepare_cached(&visible_insert)
10415 .map_err(|e| invalid(e.to_string()))?,
10416 )
10417 } else {
10418 None
10419 };
10420 for _ in 0..1024 {
10421 let Some(row) = rows.next().map_err(|e| invalid(e.to_string()))? else {
10422 break;
10423 };
10424 for (i, column) in table.columns.iter().enumerate() {
10425 let cell = row.get_ref(i).map_err(|e| invalid(e.to_string()))?;
10426 let param = image_cell_param(column, cell).map_err(&invalid)?;
10427 ins.raw_bind_parameter(i + 1, ¶m)
10428 .map_err(|e| invalid(e.to_string()))?;
10429 if let Some(mirror) = &mut mirror {
10430 mirror
10431 .raw_bind_parameter(i + 1, ¶m)
10432 .map_err(|e| invalid(e.to_string()))?;
10433 }
10434 }
10435 let version_index = table.columns.len();
10436 let version: i64 =
10437 row.get(version_index).map_err(|e| invalid(e.to_string()))?;
10438 if version < 1 {
10439 return Err(invalid("image row version must be positive".into()));
10440 }
10441 ins.raw_bind_parameter(version_index + 1, version)
10442 .map_err(|e| invalid(e.to_string()))?;
10443 ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
10444 if let Some(mirror) = &mut mirror {
10445 mirror
10446 .raw_bind_parameter(version_index + 1, version)
10447 .map_err(|e| invalid(e.to_string()))?;
10448 mirror.raw_execute().map_err(|e| invalid(e.to_string()))?;
10449 }
10450 if reconcile_pending {
10451 changed_keys.push(
10452 row.get::<_, SqlValue>(table.pk_index)
10453 .map_err(|error| invalid(error.to_string()))?,
10454 );
10455 }
10456 processed += 1;
10457 }
10458 }
10459 if processed > 0 {
10460 batch.table(&table.name);
10461 self.overlay_dirty.set(true);
10462 }
10463 if reconcile_pending {
10464 self.reconcile_imported_rows(table, &mut changed_keys)
10465 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10466 }
10467 self.apply_outbox_ops(
10468 self.outbox
10469 .iter()
10470 .flat_map(|commit| &commit.ops)
10471 .filter(|op| op.table == table.name),
10472 );
10473 self.overlay_dirty.set(false);
10474 self.finish_observation("syncular_image_chunk", batch)
10475 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10476 Ok(processed)
10477 })();
10478 let processed = match result {
10479 Ok(processed) => processed,
10480 Err(error) => {
10481 self.rollback_observation("syncular_image_chunk");
10482 self.overlay_dirty.set(was_dirty);
10483 return Err(error);
10484 }
10485 };
10486 applied += processed;
10487 self.progress
10488 .update(|p| p.rows_processed = u64::from(applied));
10489 std::thread::yield_now();
10490 if i64::from(applied) == actual_count {
10491 break;
10492 }
10493 }
10494 Ok(applied)
10495 }
10496
10497 fn reconcile_imported_rows(
10501 &self,
10502 table: &TableSchema,
10503 keys: &mut Vec<SqlValue>,
10504 ) -> Result<(), String> {
10505 let visible = visible_table(&table.name);
10506 let base = base_table(&table.name);
10507 let pk = quote_ident(&table.primary_key);
10508 let predicate = row_id_predicate(table);
10509 let mut lookup = self.conn.prepare_cached(&format!(
10510 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
10511 )).map_err(|error| error.to_string())?;
10512 let mut deleted = HashSet::new();
10513 for op in self
10514 .outbox
10515 .iter()
10516 .flat_map(|commit| &commit.ops)
10517 .filter(|op| op.table == table.name)
10518 {
10519 if op.upsert {
10520 if let Some(values) = &op.values {
10521 let key = json_to_column_value(
10522 &table.columns[table.pk_index],
10523 values.get(&table.primary_key),
10524 )?;
10525 keys.push(owned_sql_value(RowParam::Cell(&key))?);
10526 }
10527 } else if deleted.insert(&op.row_id) {
10528 keys.extend(
10529 lookup
10530 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
10531 .map_err(|error| error.to_string())?
10532 .collect::<Result<Vec<_>, _>>()
10533 .map_err(|error| error.to_string())?,
10534 );
10535 }
10536 }
10537 for chunk in keys.chunks(400) {
10540 let holes = vec!["?"; chunk.len()].join(",");
10541 self.conn
10542 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
10543 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10544 .map_err(|error| error.to_string())?;
10545 }
10546 let updates = table
10547 .columns
10548 .iter()
10549 .map(|column| column.name.as_str())
10550 .chain(std::iter::once("_syncular_version"))
10551 .filter(|name| *name != table.primary_key)
10552 .map(|name| {
10553 let name = quote_ident(name);
10554 format!("{name}=excluded.{name}")
10555 })
10556 .collect::<Vec<_>>()
10557 .join(",");
10558 for chunk in keys.chunks(400) {
10559 let holes = vec!["?"; chunk.len()].join(",");
10560 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
10561 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10562 .map_err(|error| error.to_string())?;
10563 }
10564 Ok(())
10565 }
10566
10567 fn compile_local_data_purge(
10570 &self,
10571 input: &LocalDataPurgeInput,
10572 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
10573 let invalid = |message: String| format!("sync.invalid_request: {message}");
10574 if input.purge_id.is_empty()
10575 || input.purge_id.len() > 128
10576 || !is_local_operation_code_like(&input.purge_id)
10577 {
10578 return Err(invalid(
10579 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
10580 ));
10581 }
10582 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
10583 return Err(invalid(format!(
10584 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
10585 )));
10586 }
10587
10588 let mut deduplicated: BTreeMap<
10589 String,
10590 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
10591 > = BTreeMap::new();
10592 for target in &input.targets {
10593 let table = self.schema.table(&target.table).ok_or_else(|| {
10594 invalid(format!(
10595 "local purge names unknown table {:?}",
10596 target.table
10597 ))
10598 })?;
10599 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
10600 return Err(invalid(format!(
10601 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
10602 target.table
10603 )));
10604 }
10605 let mut selectors = Vec::with_capacity(target.selectors.len());
10606 let mut canonical_selectors = BTreeMap::new();
10607 for (column_name, raw_values) in &target.selectors {
10608 let Some((column_index, column)) = table
10609 .columns
10610 .iter()
10611 .enumerate()
10612 .find(|(_, column)| column.name == *column_name)
10613 else {
10614 return Err(invalid(format!(
10615 "local purge target {:?} names unknown column {:?}",
10616 target.table, column_name
10617 )));
10618 };
10619 let encrypted = table
10620 .encrypted_columns
10621 .iter()
10622 .any(|candidate| candidate.index == column_index);
10623 if column.ty != ColumnType::String || encrypted {
10624 return Err(invalid(format!(
10625 "local purge selector {:?}.{:?} must be a plaintext string column",
10626 target.table, column_name
10627 )));
10628 }
10629 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
10630 return Err(invalid(format!(
10631 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
10632 target.table, column_name
10633 )));
10634 }
10635 let mut values = raw_values.clone();
10636 values.sort();
10637 values.dedup();
10638 if values.iter().any(|value| {
10639 value.is_empty()
10640 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
10641 || !is_local_operation_code_like(value)
10642 }) {
10643 return Err(invalid(format!(
10644 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
10645 )));
10646 }
10647 selectors.push((column_name.clone(), values.clone()));
10648 canonical_selectors.insert(column_name.clone(), values);
10649 }
10650 let canonical = LocalDataPurgeTarget {
10651 table: target.table.clone(),
10652 selectors: canonical_selectors,
10653 };
10654 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10655 deduplicated.insert(
10656 key,
10657 (
10658 CompiledLocalDataPurgeTarget {
10659 table: target.table.clone(),
10660 selectors,
10661 },
10662 canonical,
10663 ),
10664 );
10665 }
10666 let targets = deduplicated
10667 .values()
10668 .map(|(compiled, _)| compiled.clone())
10669 .collect::<Vec<_>>();
10670 let canonical = deduplicated
10671 .values()
10672 .map(|(_, target)| target.clone())
10673 .collect::<Vec<_>>();
10674 let canonical_plan =
10675 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10676 Ok((targets, canonical_plan))
10677 }
10678
10679 fn local_purge_base_row_ids(
10680 &self,
10681 targets: &[CompiledLocalDataPurgeTarget],
10682 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
10683 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10684 for target in targets {
10685 let table = self
10686 .schema
10687 .table(&target.table)
10688 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
10689 let mut clauses = Vec::with_capacity(target.selectors.len());
10690 let mut params = Vec::new();
10691 for (column, values) in &target.selectors {
10692 clauses.push(format!(
10693 "{} IN ({})",
10694 quote_ident(column),
10695 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
10696 ));
10697 params.extend(values.iter().cloned().map(SqlValue::Text));
10698 }
10699 let sql = format!(
10700 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
10701 quote_ident(&table.primary_key),
10702 base_table(&target.table),
10703 clauses.join(" AND ")
10704 );
10705 let mut statement = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
10706 let rows = statement
10707 .query_map(rusqlite::params_from_iter(params), |row| {
10708 row.get::<_, String>(0)
10709 })
10710 .map_err(|error| error.to_string())?;
10711 let ids = by_table.entry(target.table.clone()).or_default();
10712 for row in rows {
10713 ids.insert(row.map_err(|error| error.to_string())?);
10714 }
10715 }
10716 Ok(by_table)
10717 }
10718
10719 fn local_purge_values_match(
10720 target: &CompiledLocalDataPurgeTarget,
10721 values: &Map<String, Value>,
10722 ) -> bool {
10723 target.selectors.iter().all(|(column, allowed)| {
10724 values
10725 .get(column)
10726 .and_then(Value::as_str)
10727 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
10728 })
10729 }
10730
10731 pub fn purge_local_data(
10735 &mut self,
10736 input: &LocalDataPurgeInput,
10737 ) -> Result<LocalDataPurgeResult, String> {
10738 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
10739 let meta_key = format!("localPurge:{}", input.purge_id);
10740 let applied_plan = self
10741 .conn
10742 .query_row(
10743 "SELECT value FROM _syncular_meta WHERE key = ?1",
10744 rusqlite::params![meta_key],
10745 |row| row.get::<_, String>(0),
10746 )
10747 .optional()
10748 .map_err(|error| error.to_string())?;
10749 if let Some(applied_plan) = applied_plan {
10750 if applied_plan != canonical_plan {
10751 return Err(format!(
10752 "sync.invalid_request: local purge id {:?} was already used with a different plan",
10753 input.purge_id
10754 ));
10755 }
10756 return Ok(LocalDataPurgeResult {
10757 already_applied: true,
10758 purged_rows: 0,
10759 dropped_commits: 0,
10760 });
10761 }
10762
10763 let _ = self.drop_previous_version();
10767
10768 let prior_outbox = self.outbox.clone();
10769 let prior_rejection_count = self.rejections.len();
10770 let prior_overlay_dirty = self.overlay_dirty.get();
10771 self.begin_observation("syncular_local_purge")?;
10772 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
10773 let row_ids = self.local_purge_base_row_ids(&targets)?;
10774 let doomed = self
10775 .outbox
10776 .iter()
10777 .filter(|commit| {
10778 commit.ops.iter().any(|operation| {
10779 let matching_targets = targets
10780 .iter()
10781 .filter(|target| target.table == operation.table)
10782 .collect::<Vec<_>>();
10783 if matching_targets.is_empty() {
10784 return false;
10785 }
10786 if row_ids
10787 .get(&operation.table)
10788 .is_some_and(|ids| ids.contains(&operation.row_id))
10789 {
10790 return true;
10791 }
10792 operation.values.as_ref().is_some_and(|values| {
10793 matching_targets
10794 .iter()
10795 .any(|target| Self::local_purge_values_match(target, values))
10796 })
10797 })
10798 })
10799 .cloned()
10800 .collect::<Vec<_>>();
10801 let doomed_ids = doomed
10802 .iter()
10803 .map(|commit| commit.client_commit_id.clone())
10804 .collect::<BTreeSet<_>>();
10805 let mut batch = ChangeAccumulator::default();
10806 let mut rejections = Vec::new();
10807 for commit in &doomed {
10808 for operation in &commit.ops {
10809 batch.table(&operation.table);
10810 }
10811 let results = commit
10812 .ops
10813 .iter()
10814 .enumerate()
10815 .map(|(op_index, operation)| {
10816 let rejection = RejectionRecord {
10817 client_commit_id: commit.client_commit_id.clone(),
10818 op_index: op_index as i32,
10819 code: "client.local_data_purged".to_owned(),
10820 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
10821 retryable: false,
10822 details: None,
10823 operation: Some(CommitOperation::from(operation)),
10824 };
10825 rejections.push(rejection.clone());
10826 CommitOperationOutcome::Error { rejection }
10827 })
10828 .collect::<Vec<_>>();
10829 self.persist_commit_outcome(
10830 &commit.client_commit_id,
10831 CommitOutcomeStatus::Rejected,
10832 &results,
10833 Some(&commit.ops),
10834 )?;
10835 self.delete_outbox_persisted(&commit.client_commit_id)?;
10836 }
10837 if !doomed.is_empty() {
10838 self.outbox
10839 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
10840 self.rejections.extend(rejections);
10841 self.prune_commit_outcomes()?;
10842 self.overlay_dirty.set(true);
10843 batch.status = true;
10844 batch.rejections = true;
10845 batch.outcomes = true;
10846 }
10847
10848 let mut purged_rows = 0usize;
10849 for (table, ids) in &row_ids {
10850 if ids.is_empty() {
10851 continue;
10852 }
10853 batch.table(table);
10854 for row_id in ids {
10855 self.delete_base_row(table, row_id)?;
10856 purged_rows += 1;
10857 }
10858 }
10859 self.rebuild_overlay_if_dirty();
10860 self.delete_unreferenced_cached_blobs();
10861 self.conn
10862 .execute(
10863 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10864 rusqlite::params![meta_key, canonical_plan],
10865 )
10866 .map_err(|error| error.to_string())?;
10867 Ok((
10868 batch,
10869 LocalDataPurgeResult {
10870 already_applied: false,
10871 purged_rows,
10872 dropped_commits: doomed.len(),
10873 },
10874 ))
10875 })();
10876 let (batch, result) = match applied {
10877 Ok(value) => value,
10878 Err(error) => {
10879 self.rollback_observation("syncular_local_purge");
10880 self.outbox = prior_outbox;
10881 self.rejections.truncate(prior_rejection_count);
10882 self.overlay_dirty.set(prior_overlay_dirty);
10883 return Err(error);
10884 }
10885 };
10886 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
10887 self.rollback_observation("syncular_local_purge");
10888 self.outbox = prior_outbox;
10889 self.rejections.truncate(prior_rejection_count);
10890 self.overlay_dirty.set(prior_overlay_dirty);
10891 return Err(error);
10892 }
10893 Ok(result)
10894 }
10895
10896 pub fn rebootstrap_local_data(
10902 &mut self,
10903 input: &LocalDataRebootstrapInput,
10904 ) -> Result<LocalDataRebootstrapResult, String> {
10905 if input.rebootstrap_id.is_empty()
10906 || input.rebootstrap_id.len() > 128
10907 || !is_local_operation_code_like(&input.rebootstrap_id)
10908 {
10909 return Err(
10910 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
10911 .to_owned(),
10912 );
10913 }
10914 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
10915 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
10916 let (retained_commits, reset_subscriptions) =
10917 decode_local_rebootstrap_receipt(&persisted)?;
10918 return Ok(LocalDataRebootstrapResult {
10919 already_applied: true,
10920 retained_commits,
10921 reset_subscriptions,
10922 });
10923 }
10924 if self.stopped || self.schema_floor.is_some() {
10925 return Err(
10926 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
10927 .to_owned(),
10928 );
10929 }
10930
10931 let retained_commits = self.outbox.len();
10932 let reset_subscriptions = self.subs.len();
10933 let prior_subs = self.subs.clone();
10934 let prior_upgrading = self.upgrading;
10935 let prior_stopped = self.stopped;
10936 let prior_schema_floor = self.schema_floor.clone();
10937 let prior_overlay_dirty = self.overlay_dirty.get();
10938 let prior_sync_needed = self.sync_needed;
10939 let prior_sync_intents = self.sync_intent_queue.clone();
10940 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
10941
10942 self.begin_observation("syncular_local_rebootstrap")?;
10943 let mut batch = ChangeAccumulator::default();
10944 let applied = (|| -> Result<(), String> {
10945 self.run_schema_reset_observed(&mut batch, false, false)?;
10946 self.conn
10947 .execute(
10948 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10949 rusqlite::params![meta_key, receipt],
10950 )
10951 .map_err(|error| error.to_string())?;
10952 self.sync_needed = true;
10953 self.sync_intent_queue.push_back(SyncIntent::Interactive);
10954 batch.status = true;
10955 Ok(())
10956 })();
10957 if let Err(error) = applied {
10958 self.rollback_observation("syncular_local_rebootstrap");
10959 self.subs = prior_subs;
10960 self.upgrading = prior_upgrading;
10961 self.stopped = prior_stopped;
10962 self.schema_floor = prior_schema_floor;
10963 self.overlay_dirty.set(prior_overlay_dirty);
10964 self.sync_needed = prior_sync_needed;
10965 self.sync_intent_queue = prior_sync_intents;
10966 return Err(error);
10967 }
10968 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
10969 self.rollback_observation("syncular_local_rebootstrap");
10970 self.subs = prior_subs;
10971 self.upgrading = prior_upgrading;
10972 self.stopped = prior_stopped;
10973 self.schema_floor = prior_schema_floor;
10974 self.overlay_dirty.set(prior_overlay_dirty);
10975 self.sync_needed = prior_sync_needed;
10976 self.sync_intent_queue = prior_sync_intents;
10977 return Err(error);
10978 }
10979
10980 Ok(LocalDataRebootstrapResult {
10981 already_applied: false,
10982 retained_commits,
10983 reset_subscriptions,
10984 })
10985 }
10986
10987 fn purge_scope_rows(
10992 &mut self,
10993 table_name: &str,
10994 effective: &[(String, Vec<String>)],
10995 ) -> Result<(), ()> {
10996 if effective.is_empty() {
10997 return Ok(());
10998 }
10999 let table = self.schema.table(table_name).ok_or(())?.clone();
11000 let mut clauses = Vec::new();
11001 let mut params: Vec<SqlValue> = Vec::new();
11002 for (variable, values) in effective {
11003 let column = table.scope_column(variable).ok_or(())?;
11004 let placeholders: Vec<String> = values
11005 .iter()
11006 .map(|v| {
11007 params.push(SqlValue::Text(v.clone()));
11008 "?".to_owned()
11009 })
11010 .collect();
11011 clauses.push(format!(
11012 "{} IN ({})",
11013 quote_ident(column),
11014 placeholders.join(", ")
11015 ));
11016 }
11017 let sql = format!(
11018 "DELETE FROM {} WHERE {}",
11019 base_table(table_name),
11020 clauses.join(" AND ")
11021 );
11022 self.overlay_dirty.set(true);
11023 self.conn
11024 .execute(&sql, rusqlite::params_from_iter(¶ms))
11025 .map_err(|_| ())?;
11026 self.conn
11027 .execute(
11028 &format!(
11029 "DELETE FROM {} WHERE {}",
11030 visible_table(table_name),
11031 clauses.join(" AND ")
11032 ),
11033 rusqlite::params_from_iter(¶ms),
11034 )
11035 .map_err(|_| ())?;
11036 Ok(())
11037 }
11038
11039 fn drop_doomed_outbox(
11042 &mut self,
11043 table_name: &str,
11044 effective: &[(String, Vec<String>)],
11045 ) -> Result<bool, String> {
11046 if effective.is_empty() {
11047 return Ok(false);
11048 }
11049 let Some(table) = self.schema.table(table_name).cloned() else {
11050 return Ok(false);
11051 };
11052 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
11053 for (variable, values) in effective {
11054 match table.scope_column(variable) {
11055 Some(column) => mappings.push((column, values)),
11056 None => return Ok(false), }
11058 }
11059 let doomed: Vec<OutboxCommit> = self
11060 .outbox
11061 .iter()
11062 .filter(|commit| {
11063 commit.ops.iter().any(|op| {
11064 op.upsert
11065 && op.table == table_name
11066 && op.values.as_ref().is_some_and(|values| {
11067 mappings
11068 .iter()
11069 .all(|(column, allowed)| match values.get(*column) {
11070 Some(Value::String(s)) => allowed.contains(s),
11071 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
11072 _ => false,
11073 })
11074 })
11075 })
11076 })
11077 .cloned()
11078 .collect();
11079 if doomed.is_empty() {
11080 return Ok(false);
11081 }
11082 let mut rejections = Vec::new();
11083 for commit in &doomed {
11084 let results = commit
11085 .ops
11086 .iter()
11087 .enumerate()
11088 .map(|(op_index, operation)| {
11089 let rejection = RejectionRecord {
11090 client_commit_id: commit.client_commit_id.clone(),
11091 op_index: op_index as i32,
11092 code: "sync.scope_revoked".to_owned(),
11093 message: "the commit was dropped because its effective scope was revoked"
11094 .to_owned(),
11095 retryable: false,
11096 details: None,
11097 operation: Some(CommitOperation::from(operation)),
11098 };
11099 rejections.push(rejection.clone());
11100 CommitOperationOutcome::Error { rejection }
11101 })
11102 .collect::<Vec<_>>();
11103 self.persist_commit_outcome(
11104 &commit.client_commit_id,
11105 CommitOutcomeStatus::Rejected,
11106 &results,
11107 Some(&commit.ops),
11108 )?;
11109 self.delete_outbox_persisted(&commit.client_commit_id)?;
11110 }
11111 self.prune_commit_outcomes()?;
11112 let doomed_ids = doomed
11113 .iter()
11114 .map(|commit| commit.client_commit_id.as_str())
11115 .collect::<BTreeSet<_>>();
11116 self.outbox
11117 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
11118 self.rejections.extend(rejections);
11119 self.overlay_dirty.set(true);
11120 Ok(true)
11121 }
11122
11123 pub fn upload_blob(
11130 &mut self,
11131 bytes: &[u8],
11132 media_type: Option<String>,
11133 name: Option<String>,
11134 ) -> Result<Value, String> {
11135 let blob_id = blob_id_for(bytes);
11136 let now = self.clock_now_ms();
11137 let tx = self.conn.transaction().map_err(|e| e.to_string())?;
11138 tx.execute(
11139 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
11140 ON CONFLICT(blob_id) DO NOTHING",
11141 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
11142 )
11143 .map_err(|e| e.to_string())?;
11144 tx.execute(
11145 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
11146 rusqlite::params![blob_id, media_type, now],
11147 )
11148 .map_err(|e| e.to_string())?;
11149 tx.commit().map_err(|e| e.to_string())?;
11150 self.enforce_blob_cache_cap();
11151 let mut obj = Map::new();
11152 obj.insert("blobId".to_owned(), Value::from(blob_id));
11153 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
11154 if let Some(mt) = media_type {
11155 obj.insert("mediaType".to_owned(), Value::from(mt));
11156 }
11157 if let Some(n) = name {
11158 obj.insert("name".to_owned(), Value::from(n));
11159 }
11160 Ok(Value::Object(obj))
11161 }
11162
11163 pub fn fetch_blob_bytes(
11167 &mut self,
11168 transport: &mut dyn Transport,
11169 blob_id_or_ref: &str,
11170 ) -> Result<FetchedBlob, (String, String)> {
11171 let simple = |m: String| ("client.failed".to_owned(), m);
11172 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
11173 blob_id_or_ref.to_owned()
11174 } else {
11175 let value: Value = serde_json::from_str(blob_id_or_ref)
11176 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
11177 value
11178 .get("blobId")
11179 .and_then(Value::as_str)
11180 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
11181 .to_owned()
11182 };
11183 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
11184 return Ok(cached);
11185 }
11186 #[cfg(feature = "bench-internals")]
11192 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
11193 let bytes = match transport
11194 .blob_download(&blob_id)
11195 .map_err(|e| (e.code, e.message))?
11196 {
11197 BlobDownload::Bytes(bytes) => bytes,
11198 BlobDownload::Url {
11199 url,
11200 url_expires_at_ms,
11201 } => {
11202 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
11204 return Err((
11205 "sync.segment_expired".to_owned(),
11206 format!(
11207 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
11208 ),
11209 ));
11210 }
11211 transport
11212 .fetch_blob_url(&url)
11213 .map_err(|e| (e.code, e.message))?
11214 }
11215 };
11216 #[cfg(feature = "bench-internals")]
11217 drop(download_phase);
11218 #[cfg(feature = "bench-internals")]
11219 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
11220 if blob_id_for(&bytes) != blob_id {
11222 return Err(simple(format!(
11223 "blob content address mismatch for {blob_id}"
11224 )));
11225 }
11226 #[cfg(feature = "bench-internals")]
11227 drop(validate_phase);
11228 #[cfg(feature = "bench-internals")]
11229 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
11230 let now = self.clock_now_ms();
11231 self.conn
11232 .execute(
11233 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
11234 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
11235 )
11236 .map_err(|e| simple(e.to_string()))?;
11237 self.conn
11238 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
11239 .map_err(|e| simple(e.to_string()))?;
11240 self.enforce_blob_cache_cap();
11241 #[cfg(feature = "bench-internals")]
11242 drop(insert_phase);
11243 self.get_cached_blob_bytes(&blob_id)
11244 .map_err(simple)?
11245 .ok_or_else(|| simple("blob cache write failed".to_owned()))
11246 }
11247
11248 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
11249 #[cfg(feature = "bench-internals")]
11250 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
11251 let mut stmt = self
11252 .conn
11253 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
11254 .map_err(|e| e.to_string())?;
11255 let mut rows = stmt
11256 .query(rusqlite::params![blob_id])
11257 .map_err(|e| e.to_string())?;
11258 if let Some(row) = rows.next().map_err(|e| e.to_string())? {
11259 let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
11260 let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
11261 let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
11262 return Ok(Some(FetchedBlob {
11263 blob_id: blob_id.to_owned(),
11264 byte_length,
11265 bytes,
11266 media_type,
11267 }));
11268 }
11269 Ok(None)
11270 }
11271
11272 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
11274 let pending: Vec<(String, Option<String>)> = {
11275 let mut stmt = self
11276 .conn
11277 .prepare(
11278 "SELECT blob_id, media_type FROM (
11279 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
11280 UNION ALL
11281 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
11282 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
11283 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
11284 ) ORDER BY created_at_ms, blob_id",
11285 )
11286 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11287 let rows = stmt
11288 .query_map([], |row| {
11289 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
11290 })
11291 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11292 rows.collect::<Result<_, _>>()
11293 .map_err(|error| match error {
11294 rusqlite::Error::InvalidColumnType(..)
11295 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
11296 "sync.local_corrupt",
11297 "Pending blob upload metadata is invalid",
11298 ),
11299 _ => TransportError::new("client.failed", error.to_string()),
11300 })?
11301 };
11302 for (blob_id, media_type) in pending {
11303 let stored: Option<(SqlValue, SqlValue)> = self
11304 .conn
11305 .query_row(
11306 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
11307 rusqlite::params![blob_id],
11308 |row| Ok((row.get(0)?, row.get(1)?)),
11309 )
11310 .optional()
11311 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11312 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
11313 return Err(TransportError::new(
11314 "sync.local_corrupt",
11315 "Pending blob upload body is missing or corrupt",
11316 ));
11317 };
11318 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
11319 return Err(TransportError::new(
11320 "sync.local_corrupt",
11321 "Pending blob upload body is missing or corrupt",
11322 ));
11323 }
11324 self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
11325 self.conn
11326 .execute(
11327 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
11328 rusqlite::params![blob_id],
11329 )
11330 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11331 }
11332 Ok(())
11333 }
11334
11335 fn upload_one(
11342 &self,
11343 transport: &mut dyn Transport,
11344 blob_id: &str,
11345 bytes: &[u8],
11346 media_type: Option<&str>,
11347 ) -> Result<(), TransportError> {
11348 match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
11349 BlobUploadGrant::Present => return Ok(()), BlobUploadGrant::Url {
11351 url,
11352 url_expires_at_ms,
11353 } => {
11354 let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
11355 if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
11356 return Ok(());
11357 }
11358 }
11360 BlobUploadGrant::None => {
11361 }
11363 }
11364 transport.blob_upload(blob_id, bytes, media_type)
11365 }
11366
11367 fn visible_blob_ids_sql(&self) -> String {
11368 let mut selects = Vec::new();
11369 for table in &self.schema.tables {
11370 for column in table
11371 .columns
11372 .iter()
11373 .filter(|column| column.ty == ColumnType::BlobRef)
11374 {
11375 let identifier = quote_ident(&column.name);
11376 selects.push(format!(
11377 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
11378 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
11379 AND json_type({identifier}, '$.blobId') = 'text'",
11380 quote_ident(&table.name),
11381 ));
11382 }
11383 }
11384 if selects.is_empty() {
11385 "SELECT NULL AS blob_id WHERE 0".to_owned()
11386 } else {
11387 selects.join(" UNION ")
11388 }
11389 }
11390
11391 fn enforce_blob_cache_cap(&self) {
11394 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
11395 return;
11396 };
11397 let mut total: i64 = self
11398 .conn
11399 .query_row(
11400 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
11401 [],
11402 |row| row.get(0),
11403 )
11404 .unwrap_or(0);
11405 if total <= max_bytes {
11406 return;
11407 }
11408 let query = format!(
11409 "SELECT blob_id, byte_length FROM _syncular_blobs
11410 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11411 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11412 AND blob_id NOT IN ({})
11413 ORDER BY created_at_ms ASC, blob_id ASC",
11414 self.visible_blob_ids_sql(),
11415 );
11416 let candidates: Vec<(String, i64)> = {
11417 let Ok(mut stmt) = self.conn.prepare(&query) else {
11418 return;
11419 };
11420 let Ok(rows) = stmt.query_map([], |row| {
11421 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
11422 }) else {
11423 return;
11424 };
11425 rows.filter_map(Result::ok).collect()
11426 };
11427 for (blob_id, byte_length) in candidates {
11428 if total <= max_bytes {
11429 break;
11430 }
11431 let _ = self.conn.execute(
11432 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
11433 rusqlite::params![blob_id],
11434 );
11435 total -= byte_length;
11436 }
11437 }
11438
11439 fn delete_unreferenced_cached_blobs(&mut self) {
11440 #[cfg(feature = "bench-internals")]
11441 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
11442 if !self.schema_has_blobs() {
11443 return;
11444 }
11445 self.rebuild_overlay_if_dirty();
11446 let _ = self.conn.execute(
11447 &format!(
11448 "DELETE FROM _syncular_blobs
11449 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11450 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11451 AND blob_id NOT IN ({})",
11452 self.visible_blob_ids_sql(),
11453 ),
11454 [],
11455 );
11456 }
11457
11458 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
11463 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
11464 return sql.clone();
11465 }
11466 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
11467 columns.push(quote_ident("_syncular_version"));
11468 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
11469 let primary_key = quote_ident(&table.primary_key);
11470 let updates = columns
11471 .iter()
11472 .filter(|column| **column != primary_key)
11473 .map(|column| format!("{column}=excluded.{column}"))
11474 .collect::<Vec<_>>()
11475 .join(", ");
11476 let sql = format!(
11477 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
11478 columns.join(", "),
11479 placeholders.join(", ")
11480 );
11481 self.insert_sql
11482 .borrow_mut()
11483 .insert(full_table.to_owned(), sql.clone());
11484 sql
11485 }
11486
11487 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
11488 self.overlay_dirty.set(true);
11489 self.write_row(&base_table(table_name), table_name, row, version)
11490 }
11491
11492 fn write_row(
11493 &self,
11494 full_table: &str,
11495 table_name: &str,
11496 row: &Row,
11497 version: i64,
11498 ) -> Result<(), String> {
11499 #[cfg(feature = "bench-internals")]
11500 let _phase = self.benchmark_phases.start(Phase::RowWrite);
11501 let table = self
11502 .schema
11503 .table(table_name)
11504 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11505 let sql = self.insert_row_sql(full_table, table);
11506 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11507 let params = row
11508 .iter()
11509 .map(RowParam::Cell)
11510 .chain(std::iter::once(RowParam::Version(version)));
11511 stmt.execute(rusqlite::params_from_iter(params))
11512 .map_err(|e| e.to_string())?;
11513 Ok(())
11514 }
11515
11516 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
11517 let table = self
11518 .schema
11519 .table(table_name)
11520 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11521 self.overlay_dirty.set(true);
11522 let sql = format!(
11523 "DELETE FROM {} WHERE {}",
11524 base_table(table_name),
11525 row_id_predicate(table)
11526 );
11527 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11528 stmt.execute(rusqlite::params![row_id])
11529 .map_err(|e| e.to_string())?;
11530 Ok(())
11531 }
11532
11533 fn rebuild_overlay_if_dirty(&mut self) {
11536 if self.overlay_dirty.get() {
11537 self.rebuild_overlay();
11538 }
11539 }
11540
11541 fn rebuild_overlay(&mut self) {
11545 #[cfg(feature = "bench-internals")]
11546 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
11547 #[cfg(test)]
11548 self.overlay_rebuild_count
11549 .set(self.overlay_rebuild_count.get() + 1);
11550 self.exec("SAVEPOINT syncular_overlay");
11551 for table in self.schema.tables.clone() {
11552 for index in &table.fts_indexes {
11553 let _ = self.drop_fts_triggers(index);
11554 }
11555 let visible = visible_table(&table.name);
11556 let base = base_table(&table.name);
11557 self.exec(&format!("DELETE FROM {visible}"));
11558 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
11559 }
11560 for commit in &self.outbox {
11561 self.apply_outbox_ops(&commit.ops);
11562 }
11563 for table in self.schema.tables.clone() {
11564 for index in &table.fts_indexes {
11565 let _ = self.rebuild_fts_projection(&table, index);
11566 let _ = self.create_fts_triggers(&table, index);
11567 }
11568 }
11569 self.exec("RELEASE syncular_overlay");
11570 self.overlay_dirty.set(false);
11571 }
11572
11573 fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
11578 #[cfg(feature = "bench-internals")]
11579 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
11580 for op in ops {
11581 #[cfg(feature = "bench-internals")]
11582 if let Some(phase) = &mut phase {
11583 phase.unit();
11584 }
11585 let _ = self.apply_outbox_op(op);
11586 }
11587 }
11588
11589 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
11590 let Some(table) = self.schema.table(&op.table) else {
11591 return Ok(());
11592 };
11593 let visible = visible_table(&table.name);
11594 if !op.upsert {
11595 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
11596 self.conn
11597 .prepare_cached(&sql)
11598 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
11599 .map_err(|e| e.to_string())?;
11600 return Ok(());
11601 }
11602 let Some(values) = op.values.as_ref() else {
11603 return Ok(());
11604 };
11605 let full = table
11610 .columns
11611 .iter()
11612 .all(|column| values.contains_key(&column.name));
11613 let local = self.visible_row(table, &op.row_id);
11614 if local.is_none() && !full {
11615 return Ok(());
11616 }
11617 let mut row: Row = Vec::with_capacity(table.columns.len());
11618 for (index, column) in table.columns.iter().enumerate() {
11619 let incoming = match values.get(&column.name) {
11620 Some(raw) => json_to_column_value(column, Some(raw)),
11621 None => Ok(local
11622 .as_ref()
11623 .and_then(|(values, _)| values.get(index).cloned())
11624 .flatten()),
11625 };
11626 match incoming {
11627 Ok(value) => row.push(value),
11628 Err(_) => return Ok(()),
11629 }
11630 }
11631 let version = local.as_ref().map_or(-1, |(_, version)| *version);
11632 self.write_row(&visible, &table.name, &row, version)
11633 .map_err(|error| {
11634 if error.starts_with("UNIQUE constraint failed:") {
11635 "sync.constraint_violation: local write violates a unique constraint".to_owned()
11636 } else {
11637 error
11638 }
11639 })
11640 }
11641
11642 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
11645 let sql = format!(
11646 "SELECT * FROM {} WHERE {}",
11647 visible_table(&table.name),
11648 row_id_predicate(table)
11649 );
11650 let mut stmt = self.conn.prepare_cached(&sql).ok()?;
11651 let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
11652 let row = rows.next().ok()??;
11653 let mut values = Vec::with_capacity(table.columns.len());
11654 for (index, column) in table.columns.iter().enumerate() {
11655 let raw = row.get_ref(index).ok()?;
11656 let json = sql_ref_to_json(column, raw);
11657 values.push(json_to_column_value(column, Some(&json)).ok()?);
11658 }
11659 let version: i64 = row.get(table.columns.len()).ok()?;
11660 Some((values, version))
11661 }
11662
11663 fn exec(&self, sql: &str) {
11664 let _ = self.conn.execute_batch(sql);
11665 }
11666
11667 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
11670 if self.realtime_connected {
11671 return Ok(());
11672 }
11673 transport
11674 .realtime_connect_for_client(&self.client_id)
11675 .map_err(|e| format!("{}: {}", e.code, e.message))?;
11676 self.realtime_connected = true;
11677 Ok(())
11678 }
11679
11680 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
11681 if !self.realtime_connected {
11682 return;
11683 }
11684 let _ = transport.realtime_close();
11685 self.realtime_connected = false;
11686 self.presence.clear(); }
11688
11689 pub fn set_presence(
11695 &mut self,
11696 transport: &mut dyn Transport,
11697 scope_key: &str,
11698 doc: Option<&Value>,
11699 ) -> Result<(), String> {
11700 if !self.realtime_connected {
11701 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
11702 }
11703 let text = encode_presence_publish(scope_key, doc);
11704 transport
11705 .realtime_send(&text)
11706 .map_err(|e| format!("{}: {}", e.code, e.message))
11707 }
11708
11709 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
11711 self.presence
11712 .get(scope_key)
11713 .map(|peers| peers.values().cloned().collect())
11714 .unwrap_or_default()
11715 }
11716
11717 fn apply_presence(
11719 &mut self,
11720 scope_key: String,
11721 kind: Option<PresenceKind>,
11722 actor_id: Option<String>,
11723 client_id: Option<String>,
11724 doc: Option<Value>,
11725 error: Option<String>,
11726 ) {
11727 if error.is_some() {
11730 return;
11731 }
11732 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
11733 return;
11734 };
11735 let peer_key = format!("{actor_id} {client_id}");
11736 match kind {
11737 PresenceKind::Leave => {
11738 if let Some(peers) = self.presence.get_mut(&scope_key) {
11739 peers.remove(&peer_key);
11740 if peers.is_empty() {
11741 self.presence.remove(&scope_key);
11742 }
11743 }
11744 }
11745 _ => {
11746 let doc = match doc {
11747 Some(Value::Object(_)) => doc.unwrap(),
11748 _ => return,
11749 };
11750 self.presence.entry(scope_key).or_default().insert(
11751 peer_key,
11752 PresencePeer {
11753 actor_id,
11754 client_id,
11755 doc,
11756 },
11757 );
11758 }
11759 }
11760 }
11761
11762 pub fn on_realtime_text(&mut self, text: &str) {
11764 match parse_control(text) {
11765 Ok(ControlMessage::Hello { requires_sync, .. }) => {
11766 if requires_sync {
11767 self.set_sync_needed(true, true);
11769 }
11770 }
11771 Ok(ControlMessage::Presence {
11772 scope_key,
11773 kind,
11774 actor_id,
11775 client_id,
11776 doc,
11777 error,
11778 ..
11779 }) => {
11780 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
11781 }
11782 Ok(ControlMessage::Wake { .. }) => {
11783 self.set_sync_needed(true, true);
11785 }
11786 _ => {}
11787 }
11788 }
11789
11790 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
11793 if self.stopped {
11794 return;
11795 }
11796 #[cfg(feature = "bench-internals")]
11797 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
11798 let message = match decode_message(bytes) {
11799 Ok(m) if m.msg_kind == MsgKind::Response => m,
11800 _ => {
11801 self.set_sync_needed(true, true);
11802 return;
11803 }
11804 };
11805 #[cfg(feature = "bench-internals")]
11806 drop(decode_phase);
11807 let mut frames = message.frames.into_iter();
11808 let mut applied_cursor: Option<i64> = None;
11809 let mut any_covered = false;
11810 let mut dropped = false;
11811 while let Some(frame) = frames.next() {
11812 let Frame::SubStart {
11813 id,
11814 status,
11815 effective_scopes,
11816 ..
11817 } = frame
11818 else {
11819 continue;
11820 };
11821 let mut body = Vec::new();
11822 let mut next_cursor: Option<i64> = None;
11823 for inner in frames.by_ref() {
11824 match inner {
11825 Frame::SubEnd {
11826 next_cursor: nc, ..
11827 } => {
11828 next_cursor = Some(nc);
11829 break;
11830 }
11831 Frame::Unknown { .. } => {}
11832 other => body.push(other),
11833 }
11834 }
11835 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
11836 dropped = true;
11837 continue;
11838 };
11839 let sub = &self.subs[sub_index];
11840 if status != SubStatus::Active
11843 || sub.state != SubState::Active
11844 || sub.bootstrap_state.is_some()
11845 || !sub.synced_once
11846 {
11847 dropped = true;
11848 continue;
11849 }
11850 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
11851 any_covered = true;
11853 continue;
11854 }
11855 let previous_effective = self.subs[sub_index].effective.clone();
11856 let previous_cursor = self.subs[sub_index].cursor;
11857 self.subs[sub_index].effective = Some(effective_scopes);
11858 let mut failed = false;
11859 for inner in body {
11860 match inner {
11861 Frame::Commit {
11862 tables, changes, ..
11863 } => {
11864 if self.apply_commit_frame(&tables, &changes).is_err() {
11865 failed = true;
11866 break;
11867 }
11868 }
11869 _ => {
11870 failed = true;
11871 break;
11872 }
11873 }
11874 }
11875 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
11876 self.apply_sub_end(sub_index, cursor, None)
11877 .ok()
11878 .map(|()| cursor)
11879 });
11880 let Some(next_cursor) = committed else {
11881 self.subs[sub_index].effective = previous_effective;
11882 self.subs[sub_index].cursor = previous_cursor;
11883 dropped = true;
11884 continue;
11885 };
11886 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
11887 }
11888 if let Some(cursor) = applied_cursor {
11889 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11891 let _ = transport.realtime_send(&ack);
11892 } else if !any_covered || dropped {
11893 self.set_sync_needed(true, true);
11895 }
11896 }
11897
11898 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
11902 if !self.realtime_connected {
11903 return;
11904 }
11905 let floor = self
11906 .subs
11907 .iter()
11908 .filter(|s| {
11909 s.state == SubState::Active
11910 && s.bootstrap_state.is_none()
11911 && s.synced_once
11912 && s.cursor >= 0
11913 })
11914 .map(|s| s.cursor)
11915 .min();
11916 if let Some(cursor) = floor {
11917 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11918 let _ = transport.realtime_send(&ack);
11919 }
11920 }
11921}
11922
11923#[cfg(test)]
11924mod previous_version_wiring_tests {
11925 use super::*;
11926 use serde_json::{json, Value};
11927
11928 fn previous_version_schema(version: i32) -> Value {
11929 json!({
11930 "version": version,
11931 "tables": [{
11932 "name": "tasks",
11933 "primaryKey": "id",
11934 "columns": [
11935 { "name": "id", "type": "string", "nullable": false },
11936 { "name": "note", "type": "string", "nullable": true }
11937 ],
11938 "scopes": []
11939 }]
11940 })
11941 }
11942
11943 fn previous_version_limits() -> crate::api::ClientLimits {
11944 crate::api::ClientLimits {
11945 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
11946 enabled: true,
11947 ..Default::default()
11948 }),
11949 ..Default::default()
11950 }
11951 }
11952
11953 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
11955 previous_version_limits()
11956 .previous_version_context
11957 .expect("configured")
11958 }
11959
11960 fn recapture_previous_version(client: &SyncClient) {
11963 let replica_path = client
11964 .previous_version_replica_path()
11965 .expect("file-backed replica");
11966 let outcome = crate::previous_version::capture_previous_version_from_replica(
11967 &client.conn,
11968 &replica_path,
11969 2,
11970 2,
11971 &previous_version_config(),
11972 1,
11973 )
11974 .expect("capture");
11975 assert!(matches!(
11976 outcome,
11977 crate::previous_version::CaptureOutcome::Captured(_)
11978 ));
11979 }
11980
11981 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
11982 Subscription {
11983 id: "wiring-sub".to_owned(),
11984 table: "tasks".to_owned(),
11985 requested: Vec::new(),
11986 params: None,
11987 cursor,
11988 bootstrap_state: bootstrap_state.map(str::to_owned),
11989 state,
11990 reason_code: None,
11991 effective: None,
11992 synced_once: false,
11993 }
11994 }
11995
11996 #[test]
11997 fn previous_version_read_reasons_come_from_live_client_state() {
11998 use crate::previous_version::{
11999 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
12000 };
12001 let path = std::env::temp_dir()
12002 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
12003 .to_string_lossy()
12004 .into_owned();
12005 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
12006 {
12007 let mut setup = SyncClient::open_path(
12008 "wiring".to_owned(),
12009 &previous_version_schema(1),
12010 previous_version_limits(),
12011 &path,
12012 )
12013 .expect("v1 install");
12014 setup
12015 .mutate(vec![Mutation::Upsert {
12016 table: "tasks".to_owned(),
12017 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
12018 base_version: None,
12019 }])
12020 .expect("seed a row");
12021 }
12022 let mut client = SyncClient::open_path(
12023 "wiring".to_owned(),
12024 &previous_version_schema(2),
12025 previous_version_limits(),
12026 &path,
12027 )
12028 .expect("v2 bump captures");
12029 assert!(std::path::Path::new(&container).exists());
12030 let spec = PreviousVersionReadSpec {
12031 table: "tasks".to_owned(),
12032 ..Default::default()
12033 };
12034
12035 recapture_previous_version(&client);
12037 client.lease_state = Some(LeaseState {
12038 lease_id: None,
12039 expires_at_ms: None,
12040 error_code: Some("lease.expired".to_owned()),
12041 });
12042 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12043 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
12044 assert!(!snapshot.available);
12045 assert!(!std::path::Path::new(&container).exists());
12046
12047 recapture_previous_version(&client);
12049 client.set_now_ms(1_000);
12050 client.lease_state = Some(LeaseState {
12051 lease_id: Some("lease".to_owned()),
12052 expires_at_ms: Some(1_000),
12053 error_code: None,
12054 });
12055 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12056 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
12057 assert!(!std::path::Path::new(&container).exists());
12058 client.lease_state = None;
12059
12060 recapture_previous_version(&client);
12062 client.subs.push(subscription(SubState::Revoked, 0, None));
12063 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12064 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
12065 assert!(!std::path::Path::new(&container).exists());
12066 client.subs.clear();
12067
12068 recapture_previous_version(&client);
12071 client
12072 .subs
12073 .push(subscription(SubState::Active, 0, Some("resume")));
12074 assert!(!client.previous_version_coverage_complete());
12075 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12076 assert!(snapshot.available);
12077 assert_eq!(snapshot.previous_version, Some(2));
12078 assert_eq!(snapshot.rows.len(), 1);
12079
12080 recapture_previous_version(&client);
12081 client.subs[0].bootstrap_state = None;
12082 assert!(client.previous_version_coverage_complete());
12083 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12084 assert_eq!(
12085 snapshot.reason,
12086 Some(PreviousVersionReason::CoverageComplete)
12087 );
12088 assert!(!std::path::Path::new(&container).exists());
12089 client.subs.clear();
12090
12091 assert!(!client.previous_version_coverage_complete());
12093
12094 drop(client);
12095 let _ = std::fs::remove_file(&path);
12096 }
12097
12098 #[test]
12105 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
12106 let path = std::env::temp_dir()
12107 .join(format!(
12108 "syncular-prev-lifetime-{}.db",
12109 uuid::Uuid::new_v4()
12110 ))
12111 .to_string_lossy()
12112 .into_owned();
12113 let container = format!(
12114 "{path}{}",
12115 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
12116 );
12117 let exists = || std::path::Path::new(&container).exists();
12118 {
12119 let mut setup = SyncClient::open_path(
12120 "lifetime".to_owned(),
12121 &previous_version_schema(1),
12122 previous_version_limits(),
12123 &path,
12124 )
12125 .expect("v1 install");
12126 setup
12127 .mutate(vec![Mutation::Upsert {
12128 table: "tasks".to_owned(),
12129 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
12130 base_version: None,
12131 }])
12132 .expect("seed a row");
12133 }
12134 let mut client = SyncClient::open_path(
12135 "lifetime".to_owned(),
12136 &previous_version_schema(2),
12137 previous_version_limits(),
12138 &path,
12139 )
12140 .expect("v2 bump captures");
12141 client.set_now_ms(1_000);
12144 assert!(exists());
12145
12146 client.lease_state = Some(LeaseState {
12148 lease_id: None,
12149 expires_at_ms: None,
12150 error_code: Some("lease.expired".to_owned()),
12151 });
12152 client.previous_version_lifetime_check();
12153 assert!(!exists());
12154 client.lease_state = None;
12155
12156 recapture_previous_version(&client);
12159 client.subs.push(subscription(SubState::Active, 0, None));
12160 client.previous_version_lifetime_check();
12161 assert!(!exists());
12162 client.subs.clear();
12163
12164 recapture_previous_version(&client);
12166 client.subs.push(subscription(SubState::Revoked, 0, None));
12167 client.previous_version_lifetime_check();
12168 assert!(!exists());
12169 client.subs.clear();
12170
12171 recapture_previous_version(&client);
12174 client
12175 .subs
12176 .push(subscription(SubState::Active, 0, Some("resume")));
12177 client.previous_version_lifetime_check();
12178 assert!(exists());
12179 client.subs.clear();
12180
12181 drop(client);
12182 let _ = std::fs::remove_file(&path);
12183 let _ = std::fs::remove_file(&container);
12184 let _ = std::fs::remove_file(format!("{container}-wal"));
12185 let _ = std::fs::remove_file(format!("{container}-shm"));
12186 }
12187}