1use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use std::cell::{Cell, RefCell};
12use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
13
14use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
15use rusqlite::{Connection, OpenFlags, OptionalExtension};
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18use sha2::{Digest, Sha256};
19use ssp2::decode::WIRE_VERSION;
20use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
21use ssp2::primitives::RawJson;
22use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
23use ssp2::{
24 decode_message, encode_message, encode_presence_publish, parse_control, ControlMessage,
25 PresenceKind,
26};
27
28use crate::api::{
29 ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30 ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31 ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
32 CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
33 CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
34 DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters, DiagnosticSubscription,
35 FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget,
36 LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation, PresencePeer,
37 PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot, QueryValue,
38 RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RowState, SchemaFloor,
39 SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot, TableChange,
40 WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
41 CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
42 MAX_DIAGNOSTIC_QUERY_FAILURES,
43};
44use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
45#[cfg(feature = "bench-internals")]
46use crate::bench::{Phase, Recorder};
47use crate::previous_version::{
48 capture_previous_version_from_replica, first_incompatibility,
49 reconcile_previous_version_at_boot, set_local_schema_descriptor,
50 sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
51 PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
52};
53use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
54use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
55use crate::values::{
56 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
57 encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
58 normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
59};
60
61const DEFAULT_ACCEPT: u8 = 0b0111;
65const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
66const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
67const ACCEPT_SQLITE: u8 = 1 << 2;
68const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
69const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
70
71pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
73const LOCAL_REVISION_KEY: &str = "localRevision";
74const CLIENT_ID_KEY: &str = "clientId";
75const LEASE_STATE_KEY: &str = "leaseState";
76const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
77const LOG_EPOCH_KEY: &str = "logEpoch";
78const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
83const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
84const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
85const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
88const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
91
92#[derive(Debug, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase", deny_unknown_fields)]
94struct PersistedLocalDataRebootstrapReceipt {
95 version: u8,
96 retained_commits: u64,
97 reset_subscriptions: u64,
98}
99
100fn invalid_local_rebootstrap_receipt() -> String {
101 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
102}
103
104fn encode_local_rebootstrap_receipt(
105 retained_commits: usize,
106 reset_subscriptions: usize,
107) -> Result<String, String> {
108 let retained_commits =
109 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
110 let reset_subscriptions =
111 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
112 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
113 return Err(invalid_local_rebootstrap_receipt());
114 }
115 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
116 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
117 retained_commits,
118 reset_subscriptions,
119 })
120 .map_err(|_| invalid_local_rebootstrap_receipt())
121}
122
123fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
124 if value == "v1" {
126 return Ok((0, 0));
127 }
128 let receipt: PersistedLocalDataRebootstrapReceipt =
129 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
130 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
131 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
132 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
133 {
134 return Err(invalid_local_rebootstrap_receipt());
135 }
136 Ok((
137 usize::try_from(receipt.retained_commits)
138 .map_err(|_| invalid_local_rebootstrap_receipt())?,
139 usize::try_from(receipt.reset_subscriptions)
140 .map_err(|_| invalid_local_rebootstrap_receipt())?,
141 ))
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145enum SubState {
146 Active,
147 Revoked,
148 Failed,
149}
150
151#[cfg(test)]
152mod observation_tests {
153 use super::*;
154 use crate::native_transport::HostTransport;
155 use crate::values::encode_row_json;
156 use serde_json::json;
157
158 fn client() -> SyncClient {
159 SyncClient::new(
160 "retry-test".to_owned(),
161 &json!({
162 "version": 1,
163 "tables": [{
164 "name": "tasks",
165 "primaryKey": "id",
166 "columns": [
167 { "name": "id", "type": "string", "nullable": false },
168 { "name": "project_id", "type": "string", "nullable": false }
169 ],
170 "scopes": [{ "pattern": "project:{project_id}" }]
171 }]
172 }),
173 ClientLimits::default(),
174 )
175 .expect("test client")
176 }
177
178 #[cfg(feature = "e2ee")]
179 #[test]
180 fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
181 let schema = json!({
182 "version": 1,
183 "tables": [{
184 "name": "secrets",
185 "primaryKey": "id",
186 "columns": [
187 { "name": "id", "type": "string", "nullable": false },
188 { "name": "encryption_key_id", "type": "string", "nullable": true },
189 { "name": "note", "type": "bytes", "nullable": true,
190 "encrypted": true, "declaredType": "string" }
191 ],
192 "scopes": []
193 }]
194 });
195 let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
196 .expect("test client");
197 let mut config = crate::values::EncryptionConfig::default();
198 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
199 config
200 .key_id_columns
201 .insert("secrets".to_owned(), "encryption_key_id".to_owned());
202 client.set_encryption(config);
203 let commit_id = client
206 .patch(
207 "secrets",
208 "ghost",
209 Map::from_iter([("note".to_owned(), json!("x"))]),
210 None,
211 )
212 .expect("patch records without an author-time encrypt failure");
213 assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
214 assert!(client.drop_unencodable_outbox().unwrap());
216 assert!(client.pending_commit_ids().is_empty());
217 let rejections = client.rejections();
218 assert_eq!(rejections.len(), 1);
219 assert_eq!(rejections[0].code, "client.encrypt_failed");
220 assert_eq!(rejections[0].client_commit_id, commit_id);
221 assert_eq!(
222 client
223 .commit_outcome(&commit_id)
224 .unwrap()
225 .map(|outcome| outcome.status),
226 Some(CommitOutcomeStatus::Rejected)
227 );
228 }
229
230 #[test]
231 fn patch_with_a_scope_column_matches_the_stored_local_row() {
232 let mut client = client();
233 client.create_synced_tables().unwrap();
234 client
235 .conn
236 .execute(
237 "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
238 [],
239 )
240 .unwrap();
241
242 client
245 .patch(
246 "tasks",
247 "t1",
248 Map::from_iter([
249 ("project_id".to_owned(), json!("p1")),
250 ("id".to_owned(), json!("t1")),
251 ]),
252 None,
253 )
254 .expect("an equal scope column is a no-op");
255 let values = client.outbox[0].ops[0]
256 .values
257 .clone()
258 .expect("the patch records values");
259 assert_eq!(values.get("id"), Some(&json!("t1")));
260 assert!(!values.contains_key("project_id"));
261
262 let differing = client.patch(
264 "tasks",
265 "t1",
266 Map::from_iter([("project_id".to_owned(), json!("p2"))]),
267 None,
268 );
269 assert!(differing
270 .expect_err("a differing scope column is rejected")
271 .contains("patch cannot write scope column"));
272
273 let absent = client.patch(
276 "tasks",
277 "ghost",
278 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
279 None,
280 );
281 assert!(absent
282 .expect_err("an absent local row is rejected")
283 .contains("patch cannot write scope column"));
284 assert_eq!(client.pending_commit_ids().len(), 1);
285 }
286
287 #[test]
288 fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
289 let schema = json!({
290 "version": 1,
291 "tables": [
292 {
293 "name": "tenants",
294 "primaryKey": "tenant_id",
295 "columns": [
296 { "name": "tenant_id", "type": "string", "nullable": false },
297 { "name": "body", "type": "string", "nullable": false }
298 ],
299 "scopes": [{ "pattern": "tenant:{tenant_id}" }]
300 },
301 {
302 "name": "buckets",
303 "primaryKey": "id",
304 "columns": [
305 { "name": "id", "type": "string", "nullable": false },
306 { "name": "bucket", "type": "float", "nullable": false },
307 { "name": "body", "type": "string", "nullable": false }
308 ],
309 "scopes": [{ "pattern": "bucket:{bucket}" }]
310 }
311 ]
312 });
313 let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
314 .expect("test client");
315 client.create_synced_tables().unwrap();
316
317 client
321 .patch(
322 "tenants",
323 "t1",
324 Map::from_iter([
325 ("tenant_id".to_owned(), json!("t1")),
326 ("body".to_owned(), json!("created")),
327 ]),
328 None,
329 )
330 .expect("the primary-key scope column is proven equal by construction");
331
332 client
334 .conn
335 .execute(
336 "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
337 [],
338 )
339 .unwrap();
340 client
341 .patch(
342 "buckets",
343 "b1",
344 Map::from_iter([
345 ("bucket".to_owned(), json!(2)),
346 ("body".to_owned(), json!("coerced")),
347 ]),
348 None,
349 )
350 .expect("the stored REAL value coerces from a supplied integer");
351 let values = client.outbox[1].ops[0]
352 .values
353 .clone()
354 .expect("the patch records values");
355 assert!(!values.contains_key("bucket"));
356
357 let differing = client.patch(
359 "buckets",
360 "b1",
361 Map::from_iter([("bucket".to_owned(), json!(3))]),
362 None,
363 );
364 assert!(differing
365 .expect_err("a differing scope value is rejected")
366 .contains("patch cannot write scope column"));
367 }
368
369 #[cfg(feature = "e2ee")]
370 #[test]
371 fn stored_key_fallback_resolves_an_integer_primary_key() {
372 let schema = json!({
373 "version": 1,
374 "tables": [{
375 "name": "patients",
376 "primaryKey": "id",
377 "columns": [
378 { "name": "id", "type": "integer", "nullable": false },
379 { "name": "encryption_key_id", "type": "string", "nullable": true },
380 { "name": "note", "type": "bytes", "nullable": true,
381 "encrypted": true, "declaredType": "string" }
382 ],
383 "scopes": []
384 }]
385 });
386 let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
387 .expect("test client");
388 client.create_synced_tables().unwrap();
389 client
390 .conn
391 .execute(
392 "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
393 VALUES (5, 'k1', NULL, 1)",
394 [],
395 )
396 .unwrap();
397 let mut config = crate::values::EncryptionConfig::default();
398 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
399 config
400 .key_id_columns
401 .insert("patients".to_owned(), "encryption_key_id".to_owned());
402 client.set_encryption(config);
403 let table = client.schema.table("patients").unwrap();
404 let fallback = client
405 .stored_key_fallback(table, "5")
406 .expect("the stored integer key resolves the fallback");
407 assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
408 }
409
410 #[test]
411 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
412 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
413 {"name":"id","type":"string","nullable":false},
414 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
415 for state in ["absent", "cached", "pinned"] {
416 let path = std::env::temp_dir().join(format!(
417 "syncular-blob-stage-{}.sqlite",
418 uuid::Uuid::new_v4()
419 ));
420 let mut client = SyncClient::open_path(
421 "stage-test".into(),
422 &schema,
423 Default::default(),
424 path.to_str().unwrap(),
425 )
426 .unwrap();
427 let bytes = b"atomic stage";
428 if state != "absent" {
429 client
430 .upload_blob(bytes, Some("text/plain".into()), None)
431 .unwrap();
432 if state == "cached" {
433 client
434 .conn
435 .execute("DELETE FROM _syncular_blob_uploads", [])
436 .unwrap();
437 }
438 }
439 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
440 client
441 .conn
442 .execute_batch(
443 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
444 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
445 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
446 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
447 )
448 .unwrap();
449 let error = client
450 .upload_blob(bytes, Some("application/octet-stream".into()), None)
451 .unwrap_err();
452 assert!(error.contains("injected stage failure"), "{state}: {error}");
453 assert!(client.conn.is_autocommit());
454 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
455 drop(client);
456 let mut reopened = SyncClient::open_path(
457 "stage-test".into(),
458 &schema,
459 Default::default(),
460 path.to_str().unwrap(),
461 )
462 .unwrap();
463 let durable = reopened
464 .query("SELECT * FROM _syncular_blobs", &[])
465 .unwrap();
466 reopened
467 .conn
468 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
469 .unwrap();
470 let retried = reopened.upload_blob(bytes, None, None).unwrap();
471 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
472 assert_eq!(
473 reopened
474 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
475 .unwrap(),
476 vec![Map::from_iter([(
477 "blob_id".into(),
478 Value::from(blob_id_for(bytes)),
479 )])]
480 );
481 drop(reopened);
482 std::fs::remove_file(&path).unwrap();
483 assert_eq!(durable, bodies, "{state}: reopened body state");
484 assert_eq!(immediate, bodies, "{state}: immediate body state");
485 }
486 }
487
488 #[test]
489 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
490 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
491 {"name":"id","type":"string","nullable":false},
492 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
493 for fault in [
494 "missing",
495 "body-type",
496 "length",
497 "hash",
498 "pin-metadata",
499 "read",
500 "pin-delete",
501 ] {
502 let path = std::env::temp_dir().join(format!(
503 "syncular-blob-upload-{}.sqlite",
504 uuid::Uuid::new_v4()
505 ));
506 let mut client = SyncClient::open_path(
507 "upload-test".into(),
508 &schema,
509 Default::default(),
510 path.to_str().unwrap(),
511 )
512 .unwrap();
513 let bytes = b"durable upload";
514 let ref_value = client
515 .upload_blob(bytes, Some("text/plain".into()), None)
516 .unwrap();
517 let commit = client
518 .mutate(vec![Mutation::Upsert {
519 table: "attachments".into(),
520 values: Map::from_iter([
521 ("id".into(), Value::from("pending")),
522 (
523 "file".into(),
524 Value::from(format!(
525 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
526 blob_id_for(bytes),
527 bytes.len()
528 )),
529 ),
530 ]),
531 base_version: None,
532 }])
533 .unwrap();
534 client
535 .conn
536 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
537 .unwrap();
538 client.conn.execute_batch(match fault {
539 "missing" => "DELETE FROM _syncular_blobs",
540 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
541 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
542 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
543 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
544 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
545 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
546 }).unwrap();
547 let state = if fault == "read" {
548 Vec::new()
549 } else {
550 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
551 };
552 let mut transport = CountingRealtimeTransport::default();
553 let error = client.flush_blob_uploads(&mut transport).unwrap_err();
554 assert_eq!(
555 error.code,
556 if matches!(fault, "read" | "pin-delete") {
557 "client.failed"
558 } else {
559 "sync.local_corrupt"
560 },
561 "{fault}"
562 );
563 assert_eq!(
564 transport.blob_uploads.len(),
565 usize::from(fault == "pin-delete"),
566 "{fault}: corrupt bytes must never be uploaded"
567 );
568 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
569 if fault != "read" {
570 assert_eq!(
571 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
572 state
573 );
574 } else {
575 client
576 .conn
577 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
578 .unwrap();
579 }
580 drop(client);
581 let mut reopened = SyncClient::open_path(
582 "upload-test".into(),
583 &schema,
584 Default::default(),
585 path.to_str().unwrap(),
586 )
587 .unwrap();
588 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
589 if fault != "read" {
590 assert_eq!(
591 reopened
592 .query("SELECT * FROM _syncular_blobs", &[])
593 .unwrap(),
594 state
595 );
596 }
597 if fault == "pin-delete" {
598 reopened
599 .conn
600 .execute_batch("DROP TRIGGER fail_pin_delete")
601 .unwrap();
602 }
603 if fault == "pin-metadata" {
604 reopened
605 .conn
606 .execute(
607 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
608 [],
609 )
610 .unwrap();
611 }
612 reopened
613 .conn
614 .execute_batch(
615 "DELETE FROM _syncular_blobs;
616 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
617 )
618 .unwrap();
619 transport.blob_uploads.clear();
620 reopened.flush_blob_uploads(&mut transport).unwrap();
621 assert_eq!(
622 transport.blob_uploads,
623 [(blob_id_for(bytes), bytes.to_vec())]
624 );
625 assert_eq!(
626 reopened
627 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
628 .unwrap(),
629 Vec::<Map<String, Value>>::new()
630 );
631 assert_eq!(reopened.pending_commit_ids(), [commit]);
632 assert_eq!(
633 reopened
634 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
635 .unwrap()
636 .unwrap()
637 .bytes,
638 bytes
639 );
640 drop(reopened);
641 std::fs::remove_file(path).unwrap();
642 }
643 }
644
645 #[test]
646 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
647 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
648 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
649 "scopes":[{"pattern":"project:{project_id}"}]}]});
650 for realtime in [false, true] {
651 for fault in [
652 "none",
653 "row",
654 "revision",
655 "commit",
656 "cursor",
657 "error",
658 "missing-end",
659 ] {
660 if realtime && fault == "missing-end" {
661 continue;
662 }
663 let path = std::env::temp_dir()
664 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
665 let mut client = SyncClient::open_path(
666 "frame-test".into(),
667 &schema,
668 Default::default(),
669 path.to_str().unwrap(),
670 )
671 .unwrap();
672 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
673 client
674 .subscribe(
675 "tasks".into(),
676 "tasks".into(),
677 vec![("project_id".into(), vec!["p1".into()])],
678 None,
679 )
680 .unwrap();
681 client.subs[0].cursor = 0;
682 client.subs[0].synced_once = true;
683 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
684 client.persist_sub(&client.subs[0]).unwrap();
685 client.realtime_state = RealtimeState::Connected;
686 client.realtime_reason_code = None;
687 client.drain_change_batches();
688 let mut transport = CountingRealtimeTransport::default();
689 let (_, meta) = client.build_request(false);
690 let mut response = Message {
691 wire_version: WIRE_VERSION,
692 msg_kind: MsgKind::Response,
693 frames: vec![
694 Frame::RespHeader {
695 required_schema_version: None,
696 latest_schema_version: None,
697 log_epoch: Some("epoch-1".into()),
698 reset_required: Some(false),
699 },
700 Frame::SubStart {
701 id: "tasks".into(),
702 status: SubStatus::Active,
703 reason_code: "".into(),
704 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
705 bootstrap: false,
706 },
707 ],
708 };
709 for (index, id) in ["first", "second"].iter().enumerate() {
710 let values = Map::from_iter([
711 ("id".into(), Value::from(*id)),
712 ("project_id".into(), Value::from("p1")),
713 ]);
714 response.frames.push(Frame::Commit {
715 commit_seq: index as i64 + 1,
716 actor_id: "writer".into(),
717 created_at_ms: 1,
718 tables: vec!["tasks".into()],
719 changes: vec![ssp2::model::Change {
720 table_index: 0,
721 row_id: (*id).into(),
722 op: Op::Upsert,
723 row_version: Some(index as i64 + 1),
724 scopes: vec![("project_id".into(), "p1".into())],
725 row: Some(
726 encode_row_json(
727 client.schema.table("tasks").unwrap(),
728 id,
729 &values,
730 &client.encryption,
731 )
732 .unwrap(),
733 ),
734 }],
735 });
736 }
737 response.frames.push(Frame::SubEnd {
738 next_cursor: 2,
739 bootstrap_state: None,
740 });
741 let valid = response.clone();
742 match fault {
743 "row" => {
744 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
745 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
746 },
747 "revision" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' AND NEW.value = '2' AND EXISTS (SELECT 1 FROM _syncular_base_tasks WHERE id = 'second') BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END").unwrap(),
748 "commit" => client.conn.execute_batch("PRAGMA foreign_keys = ON; CREATE TABLE frame_parent(id INTEGER PRIMARY KEY); CREATE TABLE frame_child(id INTEGER REFERENCES frame_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_frame AFTER INSERT ON _syncular_base_tasks WHEN NEW.id = 'second' BEGIN INSERT INTO frame_child VALUES(1); END").unwrap(),
749 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
750 "error" => {response.frames.truncate(3);response.frames.push(Frame::Error {code:"sync.invalid_request".into(),message:"later error".into(),category:"protocol".into(),retryable:false,recommended_action:"retry".into(),details:None});},
751 "missing-end" => {response.frames.truncate(3);},
752 _ => {}
753 }
754 if realtime {
755 client.on_realtime_binary(&mut transport, &encode_message(&response));
756 } else {
757 assert_eq!(
758 matches!(
759 client.process_response(&mut transport, response, &meta),
760 SyncOutcome::Ok(_)
761 ),
762 fault == "none",
763 "{fault}"
764 );
765 }
766 assert!(
767 client.conn.is_autocommit(),
768 "no open transaction after {realtime}/{fault}"
769 );
770 let count = if matches!(fault, "none" | "cursor") {
771 2
772 } else {
773 1
774 };
775 let expected_revision = count + u64::from(realtime && fault != "none");
776 assert_eq!(
777 client.local_revision(),
778 expected_revision,
779 "{realtime}/{fault}"
780 );
781 let batches = client.drain_change_batches();
782 assert_eq!(
783 batches.len(),
784 expected_revision as usize,
785 "{realtime}/{fault}"
786 );
787 let row_batches: Vec<_> = batches
788 .iter()
789 .filter(|batch| !batch.tables.is_empty())
790 .collect();
791 assert_eq!(row_batches.len(), count as usize);
792 assert_eq!(
793 row_batches
794 .iter()
795 .map(|b| b.revision.clone())
796 .collect::<Vec<_>>(),
797 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
798 );
799 for table in ["tasks", "_syncular_base_tasks"] {
800 assert_eq!(
801 client
802 .conn
803 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
804 .get::<_, u64>(0))
805 .unwrap(),
806 count,
807 "{realtime}/{fault}/{table}"
808 );
809 }
810 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
811 assert_eq!(
812 transport
813 .messages
814 .iter()
815 .filter(|m| m.contains("\"ack\""))
816 .count(),
817 usize::from(fault == "none")
818 );
819 if matches!(fault, "revision" | "commit" | "cursor") {
820 client
821 .conn
822 .execute_batch("DROP TRIGGER fail_frame")
823 .unwrap();
824 }
825 drop(client);
826 let mut reopened = SyncClient::open_path(
827 "frame-test".into(),
828 &schema,
829 Default::default(),
830 path.to_str().unwrap(),
831 )
832 .unwrap();
833 assert_eq!(reopened.local_revision(), expected_revision);
834 assert_eq!(
835 reopened
836 .query("SELECT id FROM tasks ORDER BY id", &[])
837 .unwrap()
838 .len(),
839 count as usize
840 );
841 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
842 let (_, meta) = reopened.build_request(false);
843 assert!(matches!(
844 reopened.process_response(&mut transport, valid, &meta),
845 SyncOutcome::Ok(_)
846 ));
847 assert_eq!(
848 reopened
849 .query("SELECT id FROM tasks ORDER BY id", &[])
850 .unwrap()
851 .len(),
852 2
853 );
854 assert_eq!(reopened.subs[0].cursor, 2);
855 drop(reopened);
856 std::fs::remove_file(path).unwrap();
857 }
858 }
859 }
860
861 #[test]
862 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
863 for status in [SubStatus::Reset, SubStatus::Revoked] {
864 for fault in ["subscription", "revision"] {
865 let mut client = client();
866 client
867 .subscribe(
868 "tasks".into(),
869 "tasks".into(),
870 vec![("project_id".into(), vec!["p1".into()])],
871 None,
872 )
873 .unwrap();
874 client.subs[0].cursor = 41;
875 client.subs[0].synced_once = true;
876 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
877 client.persist_sub(&client.subs[0]).unwrap();
878 client
879 .mutate(vec![Mutation::Upsert {
880 table: "tasks".into(),
881 values: Map::from_iter([
882 ("id".into(), Value::from("pending")),
883 ("project_id".into(), Value::from("p1")),
884 ]),
885 base_version: None,
886 }])
887 .unwrap();
888 let ids = client.pending_commit_ids();
889 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
890 let revision = client.local_revision();
891 client.drain_change_batches();
892 let (_, meta) = client.build_request(false);
893 client.conn.execute_batch(if fault == "subscription" {
894 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
895 } else {
896 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
897 }).unwrap();
898 let mut transport = CountingRealtimeTransport::default();
899 let mut report = SyncReport::default();
900 if status == SubStatus::Reset {
902 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
903 }
904 let result = client.process_section(
905 &mut transport,
906 "tasks",
907 status,
908 "sync.scope_revoked",
909 vec![],
910 vec![],
911 Some((42, None)),
912 &meta,
913 &mut report,
914 );
915 assert!(result.is_err(), "{status:?}/{fault}");
916 assert!(client.conn.is_autocommit());
917 assert_eq!(client.subs[0].cursor, 41);
918 assert_eq!(client.subs[0].state, SubState::Active);
919 assert_eq!(client.pending_commit_ids(), ids);
920 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
921 assert!(client.rejections.is_empty());
922 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
923 assert_eq!(client.local_revision(), revision);
924 assert!(client.drain_change_batches().is_empty());
925 assert!(report.revoked.is_empty() && report.resets.is_empty());
926 }
927 }
928 }
929
930 #[test]
931 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
932 let mut client = client();
933 client
934 .subscribe(
935 "tasks".into(),
936 "tasks".into(),
937 vec![("project_id".into(), vec!["p1".into()])],
938 None,
939 )
940 .unwrap();
941 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
942 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
943 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
944 let row = |id: &str| ssp2::segment::SegmentRow {
945 server_version: 1,
946 values: vec![
947 Some(ColumnValue::String(id.into())),
948 Some(ColumnValue::String("p1".into())),
949 ],
950 };
951 let segment = RowsSegment {
952 table: "tasks".into(),
953 schema_version: 1,
954 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
955 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
956 };
957 assert!(client.apply_segment(0, &segment, true).is_err());
958 assert!(client.conn.is_autocommit());
959 assert_eq!(
960 client
961 .query("SELECT id FROM tasks ORDER BY id", &[])
962 .unwrap(),
963 vec![Map::from_iter([("id".into(), Value::from("first"))])]
964 );
965 assert_eq!(client.local_revision(), 1);
966 assert_eq!(client.drain_change_batches().len(), 1);
967 assert_eq!(client.subs[0].cursor, -1);
968 client
969 .conn
970 .execute_batch("DROP TRIGGER fail_block")
971 .unwrap();
972 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
973 assert_eq!(client.local_revision(), 3);
974 assert_eq!(client.drain_change_batches().len(), 2);
975 }
976
977 #[test]
978 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
979 assert_eq!(
980 client().diagnostics_storage().blob_cache_bytes_approx,
981 Some(0)
982 );
983 let client = SyncClient::new(
984 "diagnostic-storage".to_owned(),
985 &json!({
986 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
987 "columns": [{"name": "id", "type": "string", "nullable": false},
988 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
989 }),
990 ClientLimits {
991 blob_cache_max_bytes: Some(5),
992 ..Default::default()
993 },
994 )
995 .unwrap();
996 let empty = client.diagnostics_storage();
997 assert_eq!(empty.status, "healthy");
998 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
999 assert_eq!(empty.retained_outcome_entries, Some(0));
1000 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1001 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1002 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1003 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1004 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1005 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1006 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1007 let written = client.diagnostics_storage();
1008 assert_eq!(written.status, "pressure");
1009 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1010 assert_eq!(written.retained_outcome_entries, Some(2));
1011 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1012 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1013 assert_eq!(
1014 written.pressure_reason_code.as_deref(),
1015 Some("client.blob_cache_over_limit")
1016 );
1017 client
1018 .conn
1019 .execute_batch(
1020 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1021 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1022 )
1023 .unwrap();
1024 let staged = client.diagnostics_storage();
1025 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1026 assert_eq!(staged.retained_outcome_entries, Some(0));
1027 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1028 assert_eq!(staged.status, "healthy");
1029 client.conn.execute_batch("ROLLBACK").unwrap();
1030 assert_eq!(client.diagnostics_storage(), written);
1031 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1032 let unreadable = client.diagnostics_storage();
1033 assert_eq!(unreadable.status, "unreadable");
1034 assert!(unreadable.database_bytes_approx.is_none());
1035 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1036 assert!(unreadable.retained_outcome_entries.is_none());
1037 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1038 assert!(unreadable.blob_cache_bytes_approx.is_none());
1039 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1040 let restored = client.diagnostics_storage();
1041 assert_eq!(restored.status, written.status);
1042 assert_eq!(
1043 restored.retained_outcome_bytes_approx,
1044 written.retained_outcome_bytes_approx
1045 );
1046 client.conn.set_prepared_statement_cache_capacity(0);
1047 assert_eq!(client.diagnostics_storage(), restored);
1048 }
1049
1050 struct ServerErrorTransport {
1052 code: &'static str,
1053 retryable: bool,
1054 }
1055
1056 impl Transport for ServerErrorTransport {
1057 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1058 Ok(encode_message(&Message {
1059 wire_version: WIRE_VERSION,
1060 msg_kind: MsgKind::Response,
1061 frames: vec![
1062 Frame::RespHeader {
1063 required_schema_version: None,
1064 latest_schema_version: None,
1065 log_epoch: Some("epoch-1".to_owned()),
1066 reset_required: Some(false),
1067 },
1068 Frame::Error {
1069 code: self.code.to_owned(),
1070 message: "server error".to_owned(),
1071 category: "internal".to_owned(),
1072 retryable: self.retryable,
1073 recommended_action: "retryLater".to_owned(),
1074 details: None,
1075 },
1076 ],
1077 }))
1078 }
1079
1080 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1081 self.sync(request)
1082 }
1083
1084 fn download_segment(
1085 &mut self,
1086 _request: &SegmentRequest,
1087 _on_progress: &mut dyn FnMut(u64),
1088 ) -> Result<Vec<u8>, TransportError> {
1089 Err(TransportError::new("sync.transport_failed", "offline"))
1090 }
1091
1092 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1093 Ok(())
1094 }
1095
1096 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1097 Ok(())
1098 }
1099
1100 fn realtime_close(&mut self) -> Result<(), TransportError> {
1101 Ok(())
1102 }
1103 }
1104
1105 #[test]
1106 fn retryable_server_error_schedules_a_background_retry() {
1107 let mut client = client();
1108 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1109 let mut transport = ServerErrorTransport {
1110 code: "sync.internal_error",
1111 retryable: true,
1112 };
1113 assert!(matches!(
1114 client.sync(&mut transport),
1115 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1116 ));
1117 assert!(matches!(
1118 client.drain_sync_intents().as_slice(),
1119 [SyncIntent::Background { delay_ms: 250 }]
1120 ));
1121 let mut transport = ServerErrorTransport {
1122 code: "sync.invalid_request",
1123 retryable: false,
1124 };
1125 assert!(matches!(
1126 client.sync(&mut transport),
1127 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1128 ));
1129 assert!(client.drain_sync_intents().is_empty());
1130 }
1131
1132 #[test]
1133 fn background_retry_deadlines_back_off_and_reset() {
1134 let mut client = client();
1135 client.schedule_background_retry();
1136 assert!(matches!(
1137 client.drain_sync_intents().as_slice(),
1138 [SyncIntent::Background { delay_ms: 250 }]
1139 ));
1140 client.schedule_background_retry();
1141 assert!(matches!(
1142 client.drain_sync_intents().as_slice(),
1143 [SyncIntent::Background { delay_ms: 500 }]
1144 ));
1145 client.reset_background_retry();
1146 client.schedule_background_retry();
1147 assert!(matches!(
1148 client.drain_sync_intents().as_slice(),
1149 [SyncIntent::Background { delay_ms: 250 }]
1150 ));
1151 }
1152
1153 #[test]
1154 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1155 let mut client = client();
1156 client
1157 .subscribe(
1158 "epoch-tasks".to_owned(),
1159 "tasks".to_owned(),
1160 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1161 None,
1162 )
1163 .expect("subscribe");
1164 client.drain_sync_intents();
1165
1166 client
1167 .run_log_epoch_reset("epoch-2")
1168 .expect("reset partition log epoch");
1169
1170 assert!(matches!(
1171 client.drain_sync_intents().as_slice(),
1172 [SyncIntent::Interactive]
1173 ));
1174 }
1175
1176 #[derive(Default)]
1177 struct CountingRealtimeTransport {
1178 connects: usize,
1179 closes: usize,
1180 messages: Vec<String>,
1181 blob_uploads: Vec<(String, Vec<u8>)>,
1182 blob_download: Option<Vec<u8>>,
1183 sync_calls: usize,
1184 realtime_sync_calls: usize,
1185 refuse_connect: bool,
1186 }
1187
1188 impl Transport for CountingRealtimeTransport {
1189 fn blob_upload(
1190 &mut self,
1191 blob_id: &str,
1192 bytes: &[u8],
1193 _media_type: Option<&str>,
1194 ) -> Result<(), TransportError> {
1195 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1196 Ok(())
1197 }
1198
1199 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1200 self.blob_download
1201 .clone()
1202 .map(BlobDownload::Bytes)
1203 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1204 }
1205
1206 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1207 self.sync_calls += 1;
1208 Err(TransportError::new("sync.transport_failed", "offline"))
1209 }
1210
1211 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1212 self.realtime_sync_calls += 1;
1213 Err(TransportError::new("sync.transport_failed", "offline"))
1214 }
1215
1216 fn download_segment(
1217 &mut self,
1218 _request: &SegmentRequest,
1219 _on_progress: &mut dyn FnMut(u64),
1220 ) -> Result<Vec<u8>, TransportError> {
1221 Err(TransportError::new("sync.transport_failed", "offline"))
1222 }
1223
1224 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1225 self.connects += 1;
1226 if self.refuse_connect {
1227 return Err(TransportError::new("transport.failed", "refused"));
1228 }
1229 Ok(())
1230 }
1231
1232 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1233 self.messages.push(text.to_owned());
1234 Ok(())
1235 }
1236
1237 fn realtime_close(&mut self) -> Result<(), TransportError> {
1238 self.closes += 1;
1239 Ok(())
1240 }
1241 }
1242
1243 #[test]
1244 fn blob_results_own_downloaded_bytes() {
1245 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1246 {"name":"id","type":"string","nullable":false},
1247 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1248 let bytes = b"owned typed result".to_vec();
1249 let blob_id = blob_id_for(&bytes);
1250 let mut transport = CountingRealtimeTransport {
1251 blob_download: Some(bytes.clone()),
1252 ..Default::default()
1253 };
1254 let result = {
1255 let mut client =
1256 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1257 .unwrap();
1258 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1259 assert_eq!(result.blob_id, blob_id);
1260 assert_eq!(result.byte_length, bytes.len() as i64);
1261 assert_eq!(result.bytes, bytes);
1262 assert_eq!(result.media_type, None);
1263 assert_eq!(
1264 client
1265 .conn
1266 .query_row(
1267 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1268 rusqlite::params![blob_id],
1269 |row| row.get::<_, i64>(0),
1270 )
1271 .unwrap(),
1272 0
1273 );
1274 result
1275 };
1276 assert_eq!(result.bytes, bytes);
1277
1278 let mut client =
1279 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1280 assert_eq!(
1281 client
1282 .fetch_blob_bytes(&mut transport, "not json")
1283 .unwrap_err(),
1284 (
1285 "client.failed".to_owned(),
1286 "blob ref is not JSON".to_owned()
1287 )
1288 );
1289 }
1290
1291 #[test]
1292 fn visible_blob_references_survive_cache_trimming() {
1293 let schema = json!({"version":1,"tables":[
1294 {"name":"attachments","primaryKey":"id","columns":[
1295 {"name":"id","type":"string","nullable":false},
1296 {"name":"body","type":"blob_ref","nullable":true},
1297 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1298 {"name":"avatars","primaryKey":"id","columns":[
1299 {"name":"id","type":"string","nullable":false},
1300 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1301 ]});
1302 let bytes = b"referenced above cap".to_vec();
1303 let blob_id = blob_id_for(&bytes);
1304 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1305 let mut client = SyncClient::new(
1306 "visible-blob-refs".to_owned(),
1307 &schema,
1308 ClientLimits {
1309 blob_cache_max_bytes: Some(1),
1310 ..Default::default()
1311 },
1312 )
1313 .unwrap();
1314 client.conn.execute(
1315 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1316 rusqlite::params![reference, reference, reference, reference],
1317 ).unwrap();
1318 client
1319 .conn
1320 .execute(
1321 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1322 rusqlite::params![reference],
1323 )
1324 .unwrap();
1325 client.conn.execute(
1326 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1327 [],
1328 ).unwrap();
1329 let mut transport = CountingRealtimeTransport {
1330 blob_download: Some(bytes.clone()),
1331 ..Default::default()
1332 };
1333
1334 assert_eq!(
1335 client
1336 .fetch_blob_bytes(&mut transport, &blob_id)
1337 .unwrap()
1338 .bytes,
1339 bytes
1340 );
1341 let rows = client
1342 .conn
1343 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1344 .unwrap()
1345 .query_map([], |row| row.get::<_, String>(0))
1346 .unwrap()
1347 .collect::<Result<Vec<_>, _>>()
1348 .unwrap();
1349 assert_eq!(rows, vec![blob_id]);
1350 }
1351
1352 #[test]
1353 fn realtime_connection_ownership_is_idempotent() {
1354 let mut client = client();
1355 let mut transport = CountingRealtimeTransport {
1356 connects: 0,
1357 closes: 0,
1358 ..Default::default()
1359 };
1360 client
1361 .connect_realtime(&mut transport)
1362 .expect("first connect");
1363 client
1364 .connect_realtime(&mut transport)
1365 .expect("idempotent connect");
1366 assert_eq!(transport.connects, 1);
1367 client.disconnect_realtime(&mut transport);
1368 client.disconnect_realtime(&mut transport);
1369 assert_eq!(transport.closes, 1);
1370 client
1371 .connect_realtime(&mut transport)
1372 .expect("deliberate reconnect");
1373 assert_eq!(transport.connects, 2);
1374 }
1375
1376 #[test]
1377 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1378 let mut client = client();
1379 client
1380 .set_realtime_policy(RealtimePolicy::Required)
1381 .expect("set policy");
1382 let mut transport = CountingRealtimeTransport::default();
1383 match client.sync(&mut transport) {
1384 SyncOutcome::RealtimeUnavailable {
1385 state,
1386 reason_code,
1387 retry_delay_ms,
1388 } => {
1389 assert_eq!(state, RealtimeState::Disconnected);
1390 assert_eq!(reason_code, None);
1391 assert_eq!(retry_delay_ms, 250);
1392 }
1393 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1394 }
1395 assert_eq!(transport.sync_calls, 0);
1396 assert!(matches!(
1397 client.drain_sync_intents().as_slice(),
1398 [SyncIntent::Background { delay_ms: 250 }]
1399 ));
1400 let snapshot = client
1401 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1402 .expect("diagnostics");
1403 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1404 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1405 assert_eq!(snapshot.host.realtime_reason_code, None);
1406 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1407 assert_eq!(
1408 snapshot
1409 .last_round
1410 .as_ref()
1411 .and_then(|round| round.error_code.clone()),
1412 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1413 );
1414 }
1415
1416 #[test]
1417 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1418 let mut client = client();
1419 client
1420 .set_realtime_policy(RealtimePolicy::Required)
1421 .expect("set policy");
1422 let mut transport = CountingRealtimeTransport {
1423 refuse_connect: true,
1424 ..Default::default()
1425 };
1426 assert!(client.connect_realtime(&mut transport).is_err());
1427 assert_eq!(client.realtime_state(), RealtimeState::Refused);
1428 let snapshot = client
1429 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1430 .expect("diagnostics");
1431 assert_eq!(
1432 snapshot.host.realtime_reason_code.as_deref(),
1433 Some("transport.failed")
1434 );
1435 match client.sync(&mut transport) {
1436 SyncOutcome::RealtimeUnavailable {
1437 state, reason_code, ..
1438 } => {
1439 assert_eq!(state, RealtimeState::Refused);
1440 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1441 }
1442 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1443 }
1444 assert_eq!(transport.sync_calls, 0);
1445 assert_eq!(transport.realtime_sync_calls, 0);
1446 }
1447
1448 #[test]
1449 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1450 let mut client = client();
1451 client
1452 .set_realtime_policy(RealtimePolicy::Required)
1453 .expect("set policy");
1454 let mut transport = CountingRealtimeTransport::default();
1455 client.connect_realtime(&mut transport).expect("connect");
1456 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1457 assert!(
1458 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1459 "a policy change cannot silently re-route a connected socket"
1460 );
1461 assert!(matches!(
1462 client.sync(&mut transport),
1463 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1464 ));
1465 assert_eq!(client.realtime_state(), RealtimeState::Lost);
1466 assert_eq!(transport.realtime_sync_calls, 1);
1467 assert_eq!(transport.sync_calls, 0);
1468 let snapshot = client
1469 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1470 .expect("diagnostics");
1471 assert_eq!(
1472 snapshot.host.realtime_reason_code.as_deref(),
1473 Some("sync.transport_failed")
1474 );
1475 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1476 match client.sync(&mut transport) {
1477 SyncOutcome::RealtimeUnavailable {
1478 state, reason_code, ..
1479 } => {
1480 assert_eq!(state, RealtimeState::Lost);
1481 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1482 }
1483 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1484 }
1485 assert_eq!(transport.sync_calls, 0);
1486 client.disconnect_realtime(&mut transport);
1487 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1488 assert_eq!(transport.closes, 1);
1489 client.connect_realtime(&mut transport).expect("reconnect");
1490 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1491 assert_eq!(transport.connects, 2);
1492 }
1493
1494 #[test]
1495 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1496 let mut client = client();
1497 client
1498 .set_realtime_policy(RealtimePolicy::Off)
1499 .expect("set policy");
1500 let mut transport = CountingRealtimeTransport::default();
1501 assert!(client.connect_realtime(&mut transport).is_err());
1502 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1503 assert!(matches!(
1504 client.sync(&mut transport),
1505 SyncOutcome::Failed { .. }
1506 ));
1507 assert_eq!(transport.sync_calls, 1);
1508 assert_eq!(transport.realtime_sync_calls, 0);
1509 let snapshot = client
1510 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1511 .expect("diagnostics");
1512 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1513 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1514 }
1515
1516 #[test]
1517 fn optional_policy_keeps_the_http_round() {
1518 let mut client = client();
1519 let mut transport = CountingRealtimeTransport::default();
1520 assert!(matches!(
1521 client.sync(&mut transport),
1522 SyncOutcome::Failed { .. }
1523 ));
1524 assert_eq!(transport.sync_calls, 1);
1525 assert_eq!(transport.realtime_sync_calls, 0);
1526 let snapshot = client
1527 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1528 .expect("diagnostics");
1529 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1530 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1531 }
1532
1533 #[test]
1534 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1535 let mut client = client();
1536 client
1537 .subscribe(
1538 "repair-tasks".to_owned(),
1539 "tasks".to_owned(),
1540 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1541 None,
1542 )
1543 .expect("subscribe");
1544 {
1545 let sub = client
1546 .subs
1547 .iter_mut()
1548 .find(|sub| sub.id == "repair-tasks")
1549 .expect("subscription");
1550 sub.cursor = 42;
1551 sub.synced_once = true;
1552 let persisted = sub.clone();
1553 client.persist_sub(&persisted).unwrap();
1554 }
1555 for table in ["_syncular_base_tasks", "tasks"] {
1556 client
1557 .conn
1558 .execute(
1559 &format!(
1560 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1561 ),
1562 rusqlite::params!["server-row", "p1"],
1563 )
1564 .expect("seed server row");
1565 }
1566 let pending = client
1567 .mutate(vec![Mutation::Upsert {
1568 table: "tasks".to_owned(),
1569 values: Map::from_iter([
1570 ("id".to_owned(), Value::from("offline-row")),
1571 ("project_id".to_owned(), Value::from("p1")),
1572 ]),
1573 base_version: None,
1574 }])
1575 .expect("queue offline work");
1576 client.drain_change_batches();
1577 client.drain_sync_intents();
1578
1579 assert_eq!(
1580 client
1581 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1582 rebootstrap_id: "support-case-001".to_owned(),
1583 })
1584 .expect("rebootstrap"),
1585 LocalDataRebootstrapResult {
1586 already_applied: false,
1587 retained_commits: 1,
1588 reset_subscriptions: 1,
1589 }
1590 );
1591 let visible_ids = client
1592 .conn
1593 .prepare("SELECT id FROM tasks ORDER BY id")
1594 .expect("prepare visible ids")
1595 .query_map([], |row| row.get::<_, String>(0))
1596 .expect("query visible ids")
1597 .collect::<Result<Vec<_>, _>>()
1598 .expect("collect visible ids");
1599 assert_eq!(visible_ids, vec!["offline-row"]);
1600 assert_eq!(client.pending_commit_ids(), vec![pending]);
1601 assert_eq!(
1602 client
1603 .subscription_state("repair-tasks")
1604 .expect("subscription")
1605 .cursor,
1606 -1
1607 );
1608 assert!(client.upgrading());
1609 assert!(client.sync_needed());
1610 assert!(matches!(
1611 client.drain_sync_intents().as_slice(),
1612 [SyncIntent::Interactive]
1613 ));
1614 assert_eq!(client.drain_change_batches().len(), 1);
1615
1616 assert_eq!(
1617 client
1618 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1619 rebootstrap_id: "support-case-001".to_owned(),
1620 })
1621 .expect("idempotent retry"),
1622 LocalDataRebootstrapResult {
1623 already_applied: true,
1624 retained_commits: 1,
1625 reset_subscriptions: 1,
1626 }
1627 );
1628 assert!(client.drain_change_batches().is_empty());
1629 }
1630
1631 #[test]
1632 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1633 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1634 assert_eq!(
1635 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1636 (3, 4)
1637 );
1638 assert_eq!(
1639 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1640 (0, 0)
1641 );
1642 for malformed in [
1643 "",
1644 "{}",
1645 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1646 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1647 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1648 ] {
1649 assert_eq!(
1650 decode_local_rebootstrap_receipt(malformed)
1651 .expect_err("malformed receipt must fail"),
1652 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1653 );
1654 }
1655 }
1656
1657 #[test]
1658 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1659 let path = std::env::temp_dir().join(format!(
1660 "syncular-rebootstrap-receipt-{}.db",
1661 uuid::Uuid::new_v4()
1662 ));
1663 let schema = json!({
1664 "version": 1,
1665 "tables": [{
1666 "name": "tasks",
1667 "primaryKey": "id",
1668 "columns": [
1669 { "name": "id", "type": "string", "nullable": false },
1670 { "name": "project_id", "type": "string", "nullable": false }
1671 ],
1672 "scopes": [{ "pattern": "project:{project_id}" }]
1673 }]
1674 });
1675 let path_string = path.to_str().expect("UTF-8 temp path");
1676
1677 {
1678 let mut first = SyncClient::open_path(
1679 "repair-restart-client".to_owned(),
1680 &schema,
1681 ClientLimits::default(),
1682 path_string,
1683 )
1684 .expect("first open");
1685 first
1686 .subscribe(
1687 "repair-tasks".to_owned(),
1688 "tasks".to_owned(),
1689 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1690 None,
1691 )
1692 .expect("subscribe");
1693 first
1694 .mutate(vec![Mutation::Upsert {
1695 table: "tasks".to_owned(),
1696 values: Map::from_iter([
1697 ("id".to_owned(), Value::from("offline-row")),
1698 ("project_id".to_owned(), Value::from("p1")),
1699 ]),
1700 base_version: None,
1701 }])
1702 .expect("queue offline work");
1703 assert_eq!(
1704 first
1705 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1706 rebootstrap_id: "restart-receipt".to_owned(),
1707 })
1708 .expect("first rebootstrap"),
1709 LocalDataRebootstrapResult {
1710 already_applied: false,
1711 retained_commits: 1,
1712 reset_subscriptions: 1,
1713 }
1714 );
1715 }
1716
1717 let mut reopened = SyncClient::open_path(
1718 "repair-restart-client".to_owned(),
1719 &schema,
1720 ClientLimits::default(),
1721 path_string,
1722 )
1723 .expect("reopen");
1724 reopened.drain_change_batches();
1725 reopened.drain_sync_intents();
1726 assert_eq!(
1727 reopened
1728 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1729 rebootstrap_id: "restart-receipt".to_owned(),
1730 })
1731 .expect("receipt replay"),
1732 LocalDataRebootstrapResult {
1733 already_applied: true,
1734 retained_commits: 1,
1735 reset_subscriptions: 1,
1736 }
1737 );
1738 assert!(reopened.drain_change_batches().is_empty());
1739 assert!(reopened.drain_sync_intents().is_empty());
1740 drop(reopened);
1741 std::fs::remove_file(path).expect("remove temp database");
1742 }
1743
1744 #[test]
1745 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1746 let mut malformed = client();
1747 malformed
1748 .subscribe(
1749 "repair-tasks".to_owned(),
1750 "tasks".to_owned(),
1751 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1752 None,
1753 )
1754 .expect("subscribe");
1755 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1756 malformed.drain_change_batches();
1757 malformed.drain_sync_intents();
1758 let malformed_error = malformed
1759 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1760 rebootstrap_id: "malformed".to_owned(),
1761 })
1762 .expect_err("malformed receipt must fail closed");
1763 assert_eq!(
1764 malformed_error,
1765 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1766 );
1767 assert!(!malformed.upgrading());
1768 assert_eq!(
1769 malformed
1770 .subscription_state("repair-tasks")
1771 .expect("unchanged subscription")
1772 .cursor,
1773 -1
1774 );
1775 assert!(malformed.drain_change_batches().is_empty());
1776 assert!(malformed.drain_sync_intents().is_empty());
1777
1778 let mut unreadable = client();
1779 unreadable
1780 .conn
1781 .execute(
1782 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1783 rusqlite::params!["server-row", "p1"],
1784 )
1785 .expect("seed visible row");
1786 unreadable
1787 .conn
1788 .execute("DROP TABLE _syncular_meta", [])
1789 .expect("break marker storage");
1790 let unreadable_error = unreadable
1791 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1792 rebootstrap_id: "unreadable".to_owned(),
1793 })
1794 .expect_err("unreadable marker storage must fail closed");
1795 assert_eq!(
1796 unreadable_error,
1797 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1798 );
1799 let visible_rows = unreadable
1800 .conn
1801 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1802 .expect("visible projection remains");
1803 assert_eq!(visible_rows, 1);
1804 assert!(!unreadable.upgrading());
1805 assert!(unreadable.drain_change_batches().is_empty());
1806 assert!(unreadable.drain_sync_intents().is_empty());
1807 }
1808
1809 #[test]
1810 fn local_rebootstrap_cannot_bypass_schema_floor() {
1811 let mut client = client();
1812 client.set_schema_floor(Some(SchemaFloor {
1813 required_schema_version: Some(2),
1814 latest_schema_version: Some(2),
1815 }));
1816 let error = client
1817 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1818 rebootstrap_id: "blocked-floor".to_owned(),
1819 })
1820 .expect_err("schema floor must block repair");
1821 assert!(error.contains("cannot bypass an active schema-floor stop"));
1822 }
1823
1824 #[test]
1825 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1826 let mut client = client();
1827 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1828 const COMMIT_COUNT: usize = 32;
1829
1830 for index in 0..COMMIT_COUNT {
1831 client
1832 .mutate(vec![Mutation::Upsert {
1833 table: "tasks".to_owned(),
1834 values: Map::from_iter([
1835 ("id".to_owned(), Value::from(format!("task-{index}"))),
1836 ("project_id".to_owned(), Value::from("project-1")),
1837 ]),
1838 base_version: None,
1839 }])
1840 .expect("queue commit");
1841 }
1842
1843 let (_, request_meta) = client.build_request(false);
1844 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1845 let mut frames = vec![Frame::RespHeader {
1846 required_schema_version: None,
1847 latest_schema_version: None,
1848 log_epoch: Some("epoch-1".to_owned()),
1849 reset_required: Some(false),
1850 }];
1851 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1852 |(index, client_commit_id)| Frame::PushResult {
1853 client_commit_id: client_commit_id.clone(),
1854 status: PushStatus::Applied,
1855 commit_seq: Some(index as i64 + 1),
1856 results: vec![OpResult::Applied { op_index: 0 }],
1857 },
1858 ));
1859 let response = Message {
1860 wire_version: WIRE_VERSION,
1861 msg_kind: MsgKind::Response,
1862 frames,
1863 };
1864 let mut transport =
1865 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1866
1867 client.overlay_rebuild_count.set(0);
1868 client.outcome_prune_count.set(0);
1869 let outcome = client.process_response(&mut transport, response, &request_meta);
1870 assert!(matches!(outcome, SyncOutcome::Ok(_)));
1871 assert!(client.pending_commit_ids().is_empty());
1872 assert_eq!(
1873 client.overlay_rebuild_count.get(),
1874 1,
1875 "one response must reconcile its acknowledged commits with one overlay rebuild"
1876 );
1877 assert_eq!(
1878 client.outcome_prune_count.get(),
1879 1,
1880 "one response must enforce outcome retention once"
1881 );
1882 }
1883
1884 #[test]
1885 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1886 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1887 "columns": [
1888 {"name":"id", "type":"string", "nullable":false},
1889 {"name":"project_id", "type":"string", "nullable":false},
1890 {"name":"title", "type":"string", "nullable":false}
1891 ], "scopes": [{"pattern":"project:{project_id}"}],
1892 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1893 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1894 }] });
1895 let mut incremental =
1896 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1897 let mut reference =
1898 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1899 let tables = vec!["tasks".to_owned()];
1900 for index in 0..32 {
1901 let id = format!("task-{}", index % 8);
1902 let values = Map::from_iter([
1903 ("id".to_owned(), Value::from(id.clone())),
1904 (
1905 "project_id".to_owned(),
1906 Value::from(format!("p{}", index % 2)),
1907 ),
1908 ("title".to_owned(), Value::from(format!("needle{index}"))),
1909 ]);
1910 let payload = encode_row_json(
1911 incremental.schema.table("tasks").unwrap(),
1912 &id,
1913 &values,
1914 &incremental.encryption,
1915 )
1916 .unwrap();
1917 let change = ssp2::model::Change {
1918 table_index: 0,
1919 row_id: id,
1920 op: if index % 5 == 0 {
1921 Op::Delete
1922 } else {
1923 Op::Upsert
1924 },
1925 row_version: Some(index + 1),
1926 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1927 row: Some(payload),
1928 };
1929 reference.overlay_dirty.set(true);
1930 for instance in [&mut incremental, &mut reference] {
1931 instance.begin_observation("mirror_test").unwrap();
1932 let mut batch = ChangeAccumulator::default();
1933 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1934 instance
1935 .apply_commit_changes(&tables, std::slice::from_ref(&change))
1936 .unwrap();
1937 instance.rebuild_overlay_if_dirty();
1938 instance.finish_observation("mirror_test", batch).unwrap();
1939 }
1940 for sql in [
1941 "SELECT * FROM tasks ORDER BY id",
1942 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1943 ] {
1944 assert_eq!(
1945 incremental.query(sql, &[]).unwrap(),
1946 reference.query(sql, &[]).unwrap()
1947 );
1948 }
1949 assert_eq!(incremental.local_revision(), reference.local_revision());
1950 assert_eq!(
1951 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1952 serde_json::to_value(reference.drain_change_batches()).unwrap()
1953 );
1954 }
1955 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1956 assert_eq!(reference.overlay_rebuild_count.get(), 32);
1957 }
1958
1959 #[test]
1960 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1961 let cases = [
1962 (
1963 "string",
1964 vec![
1965 json!("1"),
1966 json!("01"),
1967 json!(""),
1968 json!("nul\u{0}id"),
1969 json!("é"),
1970 ],
1971 ),
1972 (
1973 "integer",
1974 vec![
1975 json!(i64::MIN),
1976 json!(-1),
1977 json!(0),
1978 json!(1),
1979 json!(i64::MAX),
1980 ],
1981 ),
1982 ("boolean", vec![json!(false), json!(true)]),
1983 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
1984 ];
1985 for (kind, ids) in cases {
1986 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1987 "columns":[{"name":"id","type":kind,"nullable":false},
1988 {"name":"project_id","type":"string","nullable":false}],
1989 "scopes":[{"pattern":"project:{project_id}"}]}]});
1990 let mut client =
1991 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
1992 for (index, id) in ids.iter().enumerate() {
1993 let table = client.schema.table("tasks").unwrap();
1994 client
1995 .write_base_row(
1996 "tasks",
1997 &vec![
1998 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
1999 Some(ColumnValue::String(format!("p{index}"))),
2000 ],
2001 1,
2002 )
2003 .unwrap();
2004 }
2005 client.rebuild_overlay_if_dirty();
2006 for base in [true, false] {
2007 let full = if base {
2008 base_table("tasks")
2009 } else {
2010 visible_table("tasks")
2011 };
2012 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
2013 for row_id in [
2014 "1",
2015 "01",
2016 "1.0",
2017 "1.0000000000000002",
2018 "+1",
2019 "1e0",
2020 " 1",
2021 "-1",
2022 "0",
2023 "9223372036854775807",
2024 "-9223372036854775808",
2025 "true",
2026 "false",
2027 "",
2028 "nul\u{0}id",
2029 "é",
2030 "null",
2031 "{\"a\":1}",
2032 ] {
2033 let old_sql =
2034 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
2035 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
2036 let old = client
2037 .conn
2038 .prepare(&old_sql)
2039 .unwrap()
2040 .query_map([row_id], |row| row.get::<_, String>(0))
2041 .unwrap()
2042 .collect::<Result<Vec<_>, _>>()
2043 .unwrap();
2044 let new = client
2045 .conn
2046 .prepare_cached(&new_sql)
2047 .unwrap()
2048 .query_map([row_id], |row| row.get::<_, String>(0))
2049 .unwrap()
2050 .collect::<Result<Vec<_>, _>>()
2051 .unwrap();
2052 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
2053 let mut batch = ChangeAccumulator::default();
2054 assert_eq!(
2055 client.record_row_scopes(&mut batch, "tasks", row_id, base),
2056 !old.is_empty()
2057 );
2058 let expected = old.first().map(|scope| {
2059 BTreeMap::from([(
2060 "tasks".to_owned(),
2061 Some(BTreeSet::from([format!("project:{scope}")])),
2062 )])
2063 });
2064 assert_eq!(batch.tables, expected.unwrap_or_default());
2065 client
2066 .conn
2067 .execute_batch("SAVEPOINT lookup_delete")
2068 .unwrap();
2069 if base {
2070 client.delete_base_row("tasks", row_id).unwrap();
2071 } else {
2072 client.apply_outbox_ops(&[OutboxOp {
2073 upsert: false,
2074 table: "tasks".into(),
2075 row_id: row_id.into(),
2076 base_version: None,
2077 values: None,
2078 }]);
2079 }
2080 let remaining: i64 = client
2081 .conn
2082 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2083 row.get(0)
2084 })
2085 .unwrap();
2086 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2087 client
2088 .conn
2089 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2090 .unwrap();
2091 client.overlay_dirty.set(false);
2092 }
2093 for (predicate, expected) in [
2094 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2095 (predicate, "SEARCH"),
2096 ] {
2097 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2098 let details = client
2099 .conn
2100 .prepare(&sql)
2101 .unwrap()
2102 .query_map(["1"], |row| row.get::<_, String>(3))
2103 .unwrap()
2104 .collect::<Result<Vec<_>, _>>()
2105 .unwrap();
2106 assert!(
2107 details.iter().any(|detail| detail.contains(expected)),
2108 "{kind}: {details:?}"
2109 );
2110 }
2111 }
2112 }
2113 }
2114
2115 #[test]
2116 fn primary_keys_reject_every_ineligible_column_type() {
2117 for ty in ["float", "bytes", "crdt", "blob_ref"] {
2121 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2122 "columns":[{"name":"id","type":ty,"nullable":false},
2123 {"name":"project_id","type":"string","nullable":false}],
2124 "scopes":[{"pattern":"project:{project_id}"}]}]});
2125 let error =
2126 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2127 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2128 Err(error) => error,
2129 };
2130 assert!(
2131 error.contains("has an ineligible column type (§2.4)"),
2132 "{ty}: {error}"
2133 );
2134 }
2135 for ty in ["string", "integer", "boolean", "json"] {
2136 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2137 "columns":[{"name":"id","type":ty,"nullable":false},
2138 {"name":"project_id","type":"string","nullable":false}],
2139 "scopes":[{"pattern":"project:{project_id}"}]}]});
2140 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2141 .unwrap_or_else(|error| panic!("{ty}: {error}"));
2142 }
2143 }
2144
2145 #[test]
2146 fn pending_row_reconciliation_matches_full_fifo_replay() {
2147 for unique in [false, true] {
2148 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2149 "name": name, "primaryKey": "id", "columns": [
2150 {"name":"id", "type":"string", "nullable":false},
2151 {"name":"project_id", "type":"string", "nullable":false},
2152 {"name":"title", "type":"string", "nullable":false}],
2153 "scopes": [{"pattern":"project:{project_id}"}],
2154 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2155 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2156 })))});
2157 let mut incremental =
2158 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2159 let mut reference =
2160 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2161 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2162 for instance in [&mut incremental, &mut reference] {
2163 for table in &tables {
2164 for index in 0..12 {
2165 instance
2166 .write_base_row(
2167 table,
2168 &vec![
2169 Some(ColumnValue::String(format!("row{index}"))),
2170 Some(ColumnValue::String("p0".into())),
2171 Some(ColumnValue::String(format!("base{index}"))),
2172 ],
2173 1,
2174 )
2175 .unwrap();
2176 }
2177 }
2178 instance.rebuild_overlay_if_dirty();
2179 }
2180 for index in 0..80 {
2181 let mutations = (0..3)
2182 .map(|offset| {
2183 let table = tables[(index + offset) % 2].clone();
2184 let id = format!("row{}", (index + offset) % 16);
2185 if (index + offset) % 7 == 0 {
2186 Mutation::Delete {
2187 table,
2188 row_id: id,
2189 base_version: None,
2190 }
2191 } else {
2192 Mutation::Upsert {
2193 table,
2194 values: Map::from_iter([
2195 ("id".into(), Value::from(id)),
2196 ("project_id".into(), Value::from(format!("p{}", index % 3))),
2197 (
2198 "title".into(),
2199 Value::from(format!("needle{}", (index + offset) % 16)),
2200 ),
2201 ]),
2202 base_version: None,
2203 }
2204 }
2205 })
2206 .collect::<Vec<_>>();
2207 for instance in [&mut incremental, &mut reference] {
2208 instance.mutate(mutations.clone()).unwrap();
2209 }
2210 let changes = (0..3)
2213 .map(|offset| {
2214 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2215 let id = format!("row{}", (index + offset) % 16);
2216 let values = Map::from_iter([
2217 ("id".into(), Value::from(id.clone())),
2218 (
2219 "project_id".into(),
2220 Value::from(format!("p{}", (index + 1) % 3)),
2221 ),
2222 (
2223 "title".into(),
2224 Value::from(format!("remote{index}_{offset}")),
2225 ),
2226 ]);
2227 ssp2::model::Change {
2228 table_index: table_index as u16,
2229 row_id: id.clone(),
2230 op: if (index + offset) % 5 == 0 {
2231 Op::Delete
2232 } else {
2233 Op::Upsert
2234 },
2235 row_version: Some(index as i64 + 2),
2236 scopes: vec![],
2237 row: Some(
2238 encode_row_json(
2239 incremental.schema.table(&tables[table_index]).unwrap(),
2240 &id,
2241 &values,
2242 &incremental.encryption,
2243 )
2244 .unwrap(),
2245 ),
2246 }
2247 })
2248 .collect::<Vec<_>>();
2249 reference.overlay_dirty.set(true);
2250 incremental.apply_commit_frame(&tables, &changes).unwrap();
2251 reference.apply_commit_frame(&tables, &changes).unwrap();
2252 for table in &tables {
2253 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2254 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2255 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2256 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2257 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2258 }
2259 }
2260 assert_eq!(incremental.local_revision(), reference.local_revision());
2261 assert_eq!(
2262 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2263 serde_json::to_value(reference.drain_change_batches()).unwrap()
2264 );
2265 }
2266 assert_eq!(
2267 incremental.overlay_rebuild_count.get(),
2268 if unique { 81 } else { 21 }
2269 );
2270 assert_eq!(reference.overlay_rebuild_count.get(), 81);
2271 }
2272 }
2273
2274 #[test]
2275 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2276 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2277 "columns":[{"name":"id", "type":"string", "nullable":false},
2278 {"name":"title", "type":"string", "nullable":false}],
2279 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2280 let mut client =
2281 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2282 for (id, title) in [("a", "original"), ("b", "occupied")] {
2283 client
2284 .write_base_row(
2285 "tasks",
2286 &vec![
2287 Some(ColumnValue::String(id.into())),
2288 Some(ColumnValue::String(title.into())),
2289 ],
2290 1,
2291 )
2292 .unwrap();
2293 }
2294 client.rebuild_overlay_if_dirty();
2295 let pending = client
2296 .mutate(vec![Mutation::Upsert {
2297 table: "tasks".into(),
2298 values: Map::from_iter([
2299 ("id".into(), Value::from("a")),
2300 ("title".into(), Value::from("available")),
2301 ]),
2302 base_version: None,
2303 }])
2304 .unwrap();
2305 assert_eq!(
2306 client
2307 .conn
2308 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2309 .get::<_, String>(
2310 0
2311 ))
2312 .unwrap(),
2313 "available"
2314 );
2315 for (title, expected) in [("available", "original"), ("occupied", "available")] {
2316 let change = ssp2::model::Change {
2317 table_index: 0,
2318 row_id: "b".into(),
2319 op: Op::Upsert,
2320 row_version: Some(2),
2321 scopes: vec![],
2322 row: Some(
2323 encode_row_json(
2324 client.schema.table("tasks").unwrap(),
2325 "b",
2326 &Map::from_iter([
2327 ("id".into(), Value::from("b")),
2328 ("title".into(), Value::from(title)),
2329 ]),
2330 &client.encryption,
2331 )
2332 .unwrap(),
2333 ),
2334 };
2335 client
2336 .apply_commit_frame(&["tasks".into()], &[change])
2337 .unwrap();
2338 assert_eq!(
2339 client
2340 .conn
2341 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2342 .get::<_, String>(
2343 0
2344 ))
2345 .unwrap(),
2346 expected
2347 );
2348 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2349 }
2350 assert_eq!(client.overlay_rebuild_count.get(), 3);
2351 }
2352
2353 #[test]
2354 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2355 for failure in ["visible", "revision", "commit"] {
2356 let mut client = client();
2357 let id = client
2358 .mutate(vec![Mutation::Upsert {
2359 table: "tasks".into(),
2360 values: Map::from_iter([
2361 ("id".into(), Value::from("t1")),
2362 ("project_id".into(), Value::from("pending")),
2363 ]),
2364 base_version: None,
2365 }])
2366 .unwrap();
2367 client.drain_change_batches();
2368 let revision = client.local_revision();
2369 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2370 let trigger = match failure {
2371 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2372 "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"),
2373 _ => "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(),
2374 };
2375 client.conn.execute_batch(&trigger).unwrap();
2376 let change = ssp2::model::Change {
2377 table_index: 0,
2378 row_id: "t1".into(),
2379 op: Op::Upsert,
2380 row_version: Some(2),
2381 scopes: vec![],
2382 row: Some(
2383 encode_row_json(
2384 client.schema.table("tasks").unwrap(),
2385 "t1",
2386 &Map::from_iter([
2387 ("id".into(), Value::from("t1")),
2388 ("project_id".into(), Value::from("remote")),
2389 ]),
2390 &client.encryption,
2391 )
2392 .unwrap(),
2393 ),
2394 };
2395 assert!(client
2396 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
2397 .is_err());
2398 assert!(client.conn.is_autocommit());
2399 assert!(!client.overlay_dirty.get());
2400 assert_eq!(client.local_revision(), revision);
2401 assert!(client.drain_change_batches().is_empty());
2402 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2403 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2404 assert!(client
2405 .query("SELECT * FROM _syncular_base_tasks", &[])
2406 .unwrap()
2407 .is_empty());
2408 client
2409 .conn
2410 .execute_batch("DROP TRIGGER fail_pending")
2411 .unwrap();
2412 client
2413 .apply_commit_frame(&["tasks".into()], &[change])
2414 .unwrap();
2415 assert_eq!(client.local_revision(), revision + 1);
2416 assert_eq!(client.pending_commit_ids(), vec![id]);
2417 let mut expected = before[0].clone();
2420 expected.insert("_syncular_version".to_owned(), Value::from(2));
2421 assert_eq!(
2422 client.query("SELECT * FROM tasks", &[]).unwrap(),
2423 vec![expected]
2424 );
2425 assert_eq!(
2426 client
2427 .conn
2428 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2429 .get::<_, String>(0))
2430 .unwrap(),
2431 "remote"
2432 );
2433 }
2434 }
2435
2436 #[test]
2437 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2438 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2439 "columns": [
2440 {"name":"id", "type":"string", "nullable":false},
2441 {"name":"project_id", "type":"string", "nullable":false},
2442 {"name":"title", "type":"string", "nullable":false}],
2443 "scopes": [{"pattern":"project:{project_id}"}],
2444 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2445 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2446 let mut incremental =
2447 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2448 let mut reference =
2449 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2450 for instance in [&mut incremental, &mut reference] {
2451 for index in 0..16 {
2452 instance
2453 .write_base_row(
2454 "tasks",
2455 &vec![
2456 Some(ColumnValue::String(format!("task{index}"))),
2457 Some(ColumnValue::String("p0".to_owned())),
2458 Some(ColumnValue::String(format!("base{index}"))),
2459 ],
2460 1,
2461 )
2462 .unwrap();
2463 }
2464 instance.rebuild_overlay_if_dirty();
2465 instance.overlay_rebuild_count.set(0);
2466 }
2467 for index in 0..100 {
2468 let mutations = (0..1 + index % 4)
2469 .map(|offset| {
2470 let id = format!("task{}", (index + offset) % 24);
2471 if (index + offset) % 7 == 0 {
2472 Mutation::Delete {
2473 table: "tasks".to_owned(),
2474 row_id: id,
2475 base_version: None,
2476 }
2477 } else {
2478 Mutation::Upsert {
2479 table: "tasks".to_owned(),
2480 values: Map::from_iter([
2481 ("id".to_owned(), Value::from(id)),
2482 (
2483 "project_id".to_owned(),
2484 Value::from(format!("p{}", index % 2)),
2485 ),
2486 (
2487 "title".to_owned(),
2488 Value::from(format!("needle{}", (index + offset) % 24)),
2489 ),
2490 ]),
2491 base_version: None,
2492 }
2493 }
2494 })
2495 .collect::<Vec<_>>();
2496 reference.overlay_dirty.set(true);
2497 incremental.mutate(mutations.clone()).unwrap();
2498 reference.mutate(mutations).unwrap();
2499 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2500 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2501 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2502 }
2503 assert_eq!(incremental.local_revision(), reference.local_revision());
2504 assert_eq!(
2505 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2506 serde_json::to_value(reference.drain_change_batches()).unwrap()
2507 );
2508 }
2509 assert_eq!(incremental.outbox.len(), 100);
2510 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2511 assert_eq!(reference.overlay_rebuild_count.get(), 100);
2512 }
2513
2514 #[test]
2515 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2516 for fail_at in ["outbox", "revision"] {
2517 let mut client = client();
2518 let values = Map::from_iter([
2519 ("id".to_owned(), Value::from("t1")),
2520 ("project_id".to_owned(), Value::from("p1")),
2521 ]);
2522 let mutation = Mutation::Upsert {
2523 table: "tasks".to_owned(),
2524 values,
2525 base_version: None,
2526 };
2527 let trigger = if fail_at == "outbox" {
2528 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2529 } else {
2530 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2531 };
2532 client.conn.execute_batch(&trigger).unwrap();
2533 let revision = client.local_revision();
2534 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2535 assert_eq!(client.local_revision(), revision);
2536 assert!(client.outbox.is_empty());
2537 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2538 assert_eq!(
2539 client
2540 .conn
2541 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2542 .get::<_, i64>(0))
2543 .unwrap(),
2544 0
2545 );
2546 assert!(client.drain_change_batches().is_empty());
2547 assert!(!client.overlay_dirty.get());
2548 client
2549 .conn
2550 .execute_batch("DROP TRIGGER fail_append")
2551 .unwrap();
2552 client.mutate(vec![mutation]).unwrap();
2553 assert_eq!(client.outbox.len(), 1);
2554 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2555 }
2556 }
2557
2558 #[test]
2559 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2560 let mut client = client();
2561 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2562 let values = Map::from_iter([
2563 ("id".to_owned(), Value::from("t1")),
2564 ("project_id".to_owned(), Value::from("p1")),
2565 ]);
2566 let payload = encode_row_json(
2567 client.schema.table("tasks").unwrap(),
2568 "t1",
2569 &values,
2570 &client.encryption,
2571 )
2572 .unwrap();
2573 let first = ssp2::model::Change {
2574 table_index: 0,
2575 row_id: "t1".to_owned(),
2576 op: Op::Upsert,
2577 row_version: Some(1),
2578 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2579 row: Some(payload),
2580 };
2581 let mut invalid = first.clone();
2582 invalid.table_index = 1;
2583 let revision = client.local_revision();
2584 client.begin_observation("failed_mirror").unwrap();
2585 assert!(client
2586 .apply_commit_changes(&tables, &[first, invalid])
2587 .is_err());
2588 client.rollback_observation("failed_mirror");
2589 client.rebuild_overlay_if_dirty();
2590 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2591 assert_eq!(
2592 client
2593 .conn
2594 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2595 .get::<_, i64>(
2596 0
2597 ))
2598 .unwrap(),
2599 0
2600 );
2601 assert_eq!(client.local_revision(), revision);
2602 assert!(client.drain_change_batches().is_empty());
2603 }
2604
2605 #[test]
2606 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2607 for failure in ["journal", "revision", "commit", "later-frame"] {
2608 let mut client = client();
2609 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2610 for id in ["first", "second"] {
2611 client
2612 .mutate(vec![Mutation::Upsert {
2613 table: "tasks".to_owned(),
2614 values: Map::from_iter([
2615 ("id".to_owned(), Value::from(id)),
2616 ("project_id".to_owned(), Value::from("p1")),
2617 ]),
2618 base_version: None,
2619 }])
2620 .unwrap();
2621 }
2622 let (_, meta) = client.build_request(false);
2623 client
2625 .mutate(vec![Mutation::Upsert {
2626 table: "tasks".to_owned(),
2627 values: Map::from_iter([
2628 ("id".to_owned(), Value::from("later")),
2629 ("project_id".to_owned(), Value::from("p1")),
2630 ]),
2631 base_version: None,
2632 }])
2633 .unwrap();
2634 let ids = client.pending_commit_ids();
2635 let rows = client
2636 .query("SELECT * FROM tasks ORDER BY id", &[])
2637 .unwrap();
2638 let revision = client.local_revision();
2639 client.drain_change_batches();
2640 if failure != "later-frame" {
2641 client.conn.execute_batch(match failure {
2642 "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",
2643 "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",
2644 _ => "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",
2645 }).unwrap();
2646 }
2647 let mut response = Message {
2648 wire_version: WIRE_VERSION,
2649 msg_kind: MsgKind::Response,
2650 frames: vec![Frame::RespHeader {
2651 required_schema_version: None,
2652 latest_schema_version: None,
2653 log_epoch: Some("epoch-1".to_owned()),
2654 reset_required: Some(false),
2655 }],
2656 };
2657 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2658 response.frames.push(Frame::PushResult {
2659 client_commit_id: id.to_owned(),
2660 status: PushStatus::Applied,
2661 commit_seq: Some(1),
2662 results: vec![OpResult::Applied { op_index: 0 }],
2663 });
2664 }
2665 if failure == "later-frame" {
2666 response.frames.push(Frame::Error {
2667 code: "sync.invalid_request".to_owned(),
2668 message: "later error".to_owned(),
2669 category: "protocol".to_owned(),
2670 retryable: false,
2671 recommended_action: "retry".to_owned(),
2672 details: None,
2673 });
2674 }
2675 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2676 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2677 assert!(
2678 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2679 );
2680 if failure == "later-frame" {
2681 assert_eq!(client.pending_commit_ids(), ids[2..]);
2682 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2683 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2684 let batches = client.drain_change_batches();
2685 assert_eq!(batches.len(), 1);
2686 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2687 assert_eq!(client.local_revision(), revision + 1);
2688 response.frames.pop();
2689 } else {
2690 assert_eq!(client.pending_commit_ids(), ids);
2691 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2692 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2693 assert_eq!(client.local_revision(), revision);
2694 assert!(client.drain_change_batches().is_empty());
2695 assert_eq!(
2696 client
2697 .query("SELECT * FROM tasks ORDER BY id", &[])
2698 .unwrap(),
2699 rows
2700 );
2701 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2702 }
2703 for frame in &mut response.frames {
2704 if let Frame::PushResult { status, .. } = frame {
2705 *status = PushStatus::Cached;
2706 }
2707 }
2708 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2709 else {
2710 panic!("retry failed");
2711 };
2712 assert_eq!(
2713 report.applied,
2714 if failure == "later-frame" {
2715 vec![]
2716 } else {
2717 ids[..2].to_vec()
2718 }
2719 );
2720 assert_eq!(client.pending_commit_ids(), ids[2..]);
2721 let batches = client.drain_change_batches();
2722 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2723 if let Some(batch) = batches.first() {
2724 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2725 }
2726 assert_eq!(client.local_revision(), revision + 1);
2727 }
2728 }
2729
2730 #[test]
2731 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2732 for failure in ["revision", "journal", "commit"] {
2733 for status in ["applied", "cached", "rejected", "conflict"] {
2734 let mut client = client();
2735 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2736 for id in ["first", "later"] {
2737 client
2738 .mutate(vec![Mutation::Upsert {
2739 table: "tasks".to_owned(),
2740 values: Map::from_iter([
2741 ("id".to_owned(), Value::from(id)),
2742 ("project_id".to_owned(), Value::from("p1")),
2743 ]),
2744 base_version: None,
2745 }])
2746 .unwrap();
2747 }
2748 let (_, meta) = client.build_request(false);
2749 let ids = client.pending_commit_ids();
2750 let rows = client
2751 .query("SELECT * FROM tasks ORDER BY id", &[])
2752 .unwrap();
2753 let revision = client.local_revision();
2754 client.drain_change_batches();
2755 let (push_status, results) = match status {
2756 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2757 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2758 "conflict" => (
2759 PushStatus::Rejected,
2760 vec![OpResult::Conflict {
2761 op_index: 0,
2762 code: "sync.version_conflict".to_owned(),
2763 message: "conflict".to_owned(),
2764 server_version: 1,
2765 server_row: encode_row_json(
2766 client.schema.table("tasks").unwrap(),
2767 "first",
2768 &Map::from_iter([
2769 ("id".to_owned(), Value::from("first")),
2770 ("project_id".to_owned(), Value::from("p1")),
2771 ]),
2772 &client.encryption,
2773 )
2774 .unwrap(),
2775 conflict_columns: Vec::new(),
2776 }],
2777 ),
2778 _ => (
2779 PushStatus::Rejected,
2780 vec![OpResult::Error {
2781 op_index: 0,
2782 code: "sync.validation_failed".to_owned(),
2783 message: "rejected".to_owned(),
2784 retryable: false,
2785 }],
2786 ),
2787 };
2788 let response = Message {
2789 wire_version: WIRE_VERSION,
2790 msg_kind: MsgKind::Response,
2791 frames: vec![
2792 Frame::RespHeader {
2793 required_schema_version: None,
2794 latest_schema_version: None,
2795 log_epoch: Some("epoch-1".to_owned()),
2796 reset_required: Some(false),
2797 },
2798 Frame::PushResult {
2799 client_commit_id: ids[0].clone(),
2800 status: push_status,
2801 commit_seq: Some(1),
2802 results,
2803 },
2804 ],
2805 };
2806 client.conn.execute_batch(match failure {
2807 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2808 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2809 _ => "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",
2810 }).unwrap();
2811 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2812 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2813 assert_eq!(
2814 client.pending_commit_ids(),
2815 ids,
2816 "{failure}/{status}: pending identity"
2817 );
2818 assert_eq!(
2819 client.local_revision(),
2820 revision,
2821 "{failure}/{status}: revision"
2822 );
2823 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2824 assert!(client.conflicts().is_empty());
2825 assert!(client.rejections().is_empty());
2826 assert!(client.drain_change_batches().is_empty());
2827 assert_eq!(
2828 client
2829 .query("SELECT * FROM tasks ORDER BY id", &[])
2830 .unwrap(),
2831 rows
2832 );
2833 assert!(
2834 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2835 "{failure}/{status}: failed round"
2836 );
2837 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2838 assert!(matches!(
2839 client.process_response(&mut transport, response, &meta),
2840 SyncOutcome::Ok(_)
2841 ));
2842 assert_eq!(client.pending_commit_ids(), ids[1..]);
2843 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2844 assert_eq!(client.drain_change_batches().len(), 1);
2845 }
2846 }
2847 }
2848
2849 #[test]
2850 fn mixed_push_results_reconcile_and_prune_once_per_response() {
2851 let mut client = client();
2852 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2853 for index in 0..4 {
2854 client
2855 .mutate(vec![Mutation::Upsert {
2856 table: "tasks".to_owned(),
2857 values: Map::from_iter([
2858 ("id".to_owned(), Value::from(format!("task-{index}"))),
2859 ("project_id".to_owned(), Value::from("project-1")),
2860 ]),
2861 base_version: None,
2862 }])
2863 .expect("queue commit");
2864 }
2865
2866 let (_, request_meta) = client.build_request(false);
2867 let ids = &request_meta.pushed_ids;
2868 assert_eq!(ids.len(), 4);
2869 let response = Message {
2870 wire_version: WIRE_VERSION,
2871 msg_kind: MsgKind::Response,
2872 frames: vec![
2873 Frame::RespHeader {
2874 required_schema_version: None,
2875 latest_schema_version: None,
2876 log_epoch: Some("epoch-1".to_owned()),
2877 reset_required: Some(false),
2878 },
2879 Frame::PushResult {
2880 client_commit_id: ids[0].clone(),
2881 status: PushStatus::Applied,
2882 commit_seq: Some(1),
2883 results: vec![OpResult::Applied { op_index: 0 }],
2884 },
2885 Frame::PushResult {
2886 client_commit_id: ids[1].clone(),
2887 status: PushStatus::Cached,
2888 commit_seq: Some(2),
2889 results: vec![OpResult::Applied { op_index: 0 }],
2890 },
2891 Frame::PushResult {
2892 client_commit_id: ids[2].clone(),
2893 status: PushStatus::Rejected,
2894 commit_seq: None,
2895 results: vec![OpResult::Error {
2896 op_index: 0,
2897 code: "sync.validation_failed".to_owned(),
2898 message: "rejected".to_owned(),
2899 retryable: false,
2900 }],
2901 },
2902 Frame::PushResult {
2903 client_commit_id: ids[3].clone(),
2904 status: PushStatus::Rejected,
2905 commit_seq: None,
2906 results: vec![OpResult::Error {
2907 op_index: 0,
2908 code: "sync.idempotency_cache_miss".to_owned(),
2909 message: "retry".to_owned(),
2910 retryable: true,
2911 }],
2912 },
2913 ],
2914 };
2915 let mut transport =
2916 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2917
2918 client.overlay_rebuild_count.set(0);
2919 client.outcome_prune_count.set(0);
2920 let outcome = client.process_response(&mut transport, response, &request_meta);
2921 let SyncOutcome::Ok(report) = outcome else {
2922 panic!("mixed push-result response failed");
2923 };
2924
2925 assert_eq!(report.applied, ids[..2]);
2926 assert_eq!(report.rejected, ids[2..3]);
2927 assert_eq!(report.retryable, ids[3..4]);
2928 assert_eq!(client.pending_commit_ids(), ids[3..4]);
2929 assert_eq!(
2930 client
2931 .query("SELECT id FROM tasks ORDER BY id", &[])
2932 .expect("query visible overlay"),
2933 vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2934 );
2935 assert_eq!(client.overlay_rebuild_count.get(), 1);
2936 assert_eq!(client.outcome_prune_count.get(), 1);
2937 }
2938
2939 #[test]
2940 fn secondary_unique_collision_preserves_existing_synced_row() {
2941 let client = SyncClient::new(
2942 "unique-upsert-test".to_owned(),
2943 &json!({
2944 "version": 1,
2945 "tables": [{
2946 "name": "tasks",
2947 "primaryKey": "id",
2948 "columns": [
2949 { "name": "id", "type": "string", "nullable": false },
2950 { "name": "project_id", "type": "string", "nullable": false },
2951 { "name": "title", "type": "string", "nullable": false }
2952 ],
2953 "scopes": [{ "pattern": "project:{project_id}" }],
2954 "indexes": [{
2955 "name": "tasks_by_project_title",
2956 "columns": ["project_id", "title"],
2957 "unique": true
2958 }]
2959 }]
2960 }),
2961 ClientLimits::default(),
2962 )
2963 .expect("test client");
2964 let table = client.schema.table("tasks").expect("tasks table");
2965 let sql = client.insert_row_sql(&base_table("tasks"), table);
2966
2967 client
2968 .conn
2969 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2970 .expect("insert first row");
2971 client
2972 .conn
2973 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
2974 .expect("update same primary key");
2975 client
2976 .conn
2977 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
2978 .expect("insert second row");
2979 assert!(client
2980 .conn
2981 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
2982 .is_err());
2983
2984 let rows = client
2985 .conn
2986 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
2987 .expect("prepare rows")
2988 .query_map([], |row| {
2989 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2990 })
2991 .expect("query rows")
2992 .collect::<Result<Vec<_>, _>>()
2993 .expect("collect rows");
2994 assert_eq!(
2995 rows,
2996 vec![
2997 ("t1".to_owned(), "updated".to_owned()),
2998 ("t2".to_owned(), "original".to_owned())
2999 ]
3000 );
3001 }
3002
3003 #[test]
3004 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
3005 let path = std::env::temp_dir().join(format!(
3006 "syncular-startup-intent-{}.db",
3007 uuid::Uuid::new_v4()
3008 ));
3009 let schema = json!({
3010 "version": 1,
3011 "tables": [{
3012 "name": "tasks",
3013 "primaryKey": "id",
3014 "columns": [
3015 { "name": "id", "type": "string", "nullable": false },
3016 { "name": "project_id", "type": "string", "nullable": false }
3017 ],
3018 "scopes": [{ "pattern": "project:{project_id}" }]
3019 }]
3020 });
3021
3022 {
3023 let mut first = SyncClient::open_path_with_identity(
3024 None,
3025 &schema,
3026 ClientLimits::default(),
3027 path.to_str().expect("UTF-8 temp path"),
3028 )
3029 .expect("first open");
3030 first
3031 .set_window(
3032 &WindowBase {
3033 table: "tasks".to_owned(),
3034 variable: "project_id".to_owned(),
3035 fixed_scopes: Vec::new(),
3036 params: None,
3037 },
3038 &["persisted".to_owned()],
3039 )
3040 .expect("persist window");
3041 }
3042
3043 let mut reopened = SyncClient::open_path_with_identity(
3044 None,
3045 &schema,
3046 ClientLimits::default(),
3047 path.to_str().expect("UTF-8 temp path"),
3048 )
3049 .expect("reopen");
3050 assert!(reopened.sync_needed());
3051 assert!(matches!(
3052 reopened.drain_sync_intents().as_slice(),
3053 [SyncIntent::Interactive]
3054 ));
3055 drop(reopened);
3056 std::fs::remove_file(path).expect("remove temp database");
3057 }
3058
3059 #[test]
3060 fn reopening_preserves_immutable_subscription_identity_and_progress() {
3061 let path = std::env::temp_dir().join(format!(
3062 "syncular-subscription-identity-{}.db",
3063 uuid::Uuid::new_v4()
3064 ));
3065 let schema = json!({
3066 "version": 1,
3067 "tables": [
3068 {
3069 "name": "tasks",
3070 "primaryKey": "id",
3071 "columns": [
3072 { "name": "id", "type": "string", "nullable": false },
3073 { "name": "project_id", "type": "string", "nullable": false }
3074 ],
3075 "scopes": [{ "pattern": "project:{project_id}" }]
3076 },
3077 {
3078 "name": "docs",
3079 "primaryKey": "id",
3080 "columns": [
3081 { "name": "id", "type": "string", "nullable": false },
3082 { "name": "org_id", "type": "string", "nullable": false },
3083 { "name": "project_id", "type": "string", "nullable": false }
3084 ],
3085 "scopes": [
3086 { "pattern": "org:{org_id}" },
3087 { "pattern": "project:{projectId}", "column": "project_id" }
3088 ]
3089 }
3090 ]
3091 });
3092
3093 {
3094 let mut first = SyncClient::open_path_with_identity(
3095 None,
3096 &schema,
3097 ClientLimits::default(),
3098 path.to_str().expect("UTF-8 temp path"),
3099 )
3100 .expect("first open");
3101 first
3102 .subscribe(
3103 "stable-subscription".to_owned(),
3104 "tasks".to_owned(),
3105 vec![(
3106 "project_id".to_owned(),
3107 vec!["p2".to_owned(), "p1".to_owned()],
3108 )],
3109 Some(r#"{"view":"v1"}"#.to_owned()),
3110 )
3111 .expect("persist subscription");
3112 let persisted = {
3113 let subscription = first
3114 .subs
3115 .iter_mut()
3116 .find(|subscription| subscription.id == "stable-subscription")
3117 .expect("subscription");
3118 subscription.cursor = 41;
3119 subscription.bootstrap_state = Some("resume-token".to_owned());
3120 subscription.effective = Some(vec![(
3121 "project_id".to_owned(),
3122 vec!["p1".to_owned(), "p2".to_owned()],
3123 )]);
3124 subscription.synced_once = true;
3125 subscription.clone()
3126 };
3127 first.persist_sub(&persisted).unwrap();
3128 }
3129
3130 let mut reopened = SyncClient::open_path_with_identity(
3131 None,
3132 &schema,
3133 ClientLimits::default(),
3134 path.to_str().expect("UTF-8 temp path"),
3135 )
3136 .expect("reopen");
3137 let progress = reopened
3138 .subscription_state("stable-subscription")
3139 .expect("persisted state");
3140 assert_eq!(progress.cursor, 41);
3141 assert!(progress.has_resume_token);
3142
3143 reopened
3144 .subscribe(
3145 "stable-subscription".to_owned(),
3146 "tasks".to_owned(),
3147 vec![(
3148 "project_id".to_owned(),
3149 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3150 )],
3151 Some(r#"{"view":"v1"}"#.to_owned()),
3152 )
3153 .expect("canonical intent is idempotent");
3154 assert_eq!(
3155 reopened
3156 .subscription_state("stable-subscription")
3157 .expect("unchanged state")
3158 .cursor,
3159 progress.cursor
3160 );
3161
3162 for (table, scopes, params) in [
3163 (
3164 "tasks",
3165 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3166 Some(r#"{"view":"v1"}"#.to_owned()),
3167 ),
3168 (
3169 "tasks",
3170 vec![(
3171 "project_id".to_owned(),
3172 vec!["p2".to_owned(), "p1".to_owned()],
3173 )],
3174 Some(r#"{"view":"v2"}"#.to_owned()),
3175 ),
3176 (
3177 "docs",
3178 vec![
3179 ("org_id".to_owned(), vec!["o1".to_owned()]),
3180 ("projectId".to_owned(), vec!["p1".to_owned()]),
3181 ],
3182 Some(r#"{"view":"v1"}"#.to_owned()),
3183 ),
3184 ] {
3185 let error = reopened
3186 .subscribe(
3187 "stable-subscription".to_owned(),
3188 table.to_owned(),
3189 scopes,
3190 params,
3191 )
3192 .expect_err("identity rebind must fail");
3193 assert!(error.starts_with("client.subscription_intent_mismatch:"));
3194 assert_eq!(
3195 reopened
3196 .subscription_state("stable-subscription")
3197 .expect("unchanged state")
3198 .cursor,
3199 progress.cursor
3200 );
3201 }
3202
3203 drop(reopened);
3204 std::fs::remove_file(path).expect("remove temp database");
3205 }
3206
3207 #[test]
3208 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
3209 let path = std::env::temp_dir().join(format!(
3210 "syncular-satisfied-schema-floor-{}.db",
3211 uuid::Uuid::new_v4()
3212 ));
3213 let schema = json!({
3214 "version": 23,
3215 "tables": [{
3216 "name": "tasks",
3217 "primaryKey": "id",
3218 "columns": [
3219 { "name": "id", "type": "string", "nullable": false },
3220 { "name": "project_id", "type": "string", "nullable": false }
3221 ],
3222 "scopes": [{ "pattern": "project:{project_id}" }]
3223 }]
3224 });
3225
3226 {
3227 let mut first = SyncClient::open_path_with_identity(
3228 None,
3229 &schema,
3230 ClientLimits::default(),
3231 path.to_str().expect("UTF-8 temp path"),
3232 )
3233 .expect("first open");
3234 first
3235 .subscribe(
3236 "tasks".to_owned(),
3237 "tasks".to_owned(),
3238 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3239 None,
3240 )
3241 .expect("persist subscription");
3242 first.set_schema_floor(Some(SchemaFloor {
3243 required_schema_version: Some(22),
3244 latest_schema_version: Some(22),
3245 }));
3246 }
3247
3248 let mut reopened = SyncClient::open_path_with_identity(
3249 None,
3250 &schema,
3251 ClientLimits::default(),
3252 path.to_str().expect("UTF-8 temp path"),
3253 )
3254 .expect("reopen");
3255 assert!(reopened.schema_floor().is_none());
3256 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3257 assert!(reopened.sync_needed());
3258 assert!(matches!(
3259 reopened.drain_sync_intents().as_slice(),
3260 [SyncIntent::Interactive]
3261 ));
3262 drop(reopened);
3263 std::fs::remove_file(path).expect("remove temp database");
3264 }
3265
3266 #[test]
3267 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3268 let path = std::env::temp_dir().join(format!(
3269 "syncular-unsatisfied-schema-floor-{}.db",
3270 uuid::Uuid::new_v4()
3271 ));
3272 let schema = json!({
3273 "version": 1,
3274 "tables": [{
3275 "name": "tasks",
3276 "primaryKey": "id",
3277 "columns": [
3278 { "name": "id", "type": "string", "nullable": false },
3279 { "name": "project_id", "type": "string", "nullable": false }
3280 ],
3281 "scopes": [{ "pattern": "project:{project_id}" }]
3282 }]
3283 });
3284
3285 {
3286 let mut first = SyncClient::open_path_with_identity(
3287 None,
3288 &schema,
3289 ClientLimits::default(),
3290 path.to_str().expect("UTF-8 temp path"),
3291 )
3292 .expect("first open");
3293 first
3294 .subscribe(
3295 "tasks".to_owned(),
3296 "tasks".to_owned(),
3297 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3298 None,
3299 )
3300 .expect("persist subscription");
3301 first.set_schema_floor(Some(SchemaFloor {
3302 required_schema_version: Some(2),
3303 latest_schema_version: Some(2),
3304 }));
3305 }
3306
3307 let reopened = SyncClient::open_path_with_identity(
3308 None,
3309 &schema,
3310 ClientLimits::default(),
3311 path.to_str().expect("UTF-8 temp path"),
3312 )
3313 .expect("reopen");
3314 assert_eq!(
3315 reopened.schema_floor(),
3316 Some(&SchemaFloor {
3317 required_schema_version: Some(2),
3318 latest_schema_version: Some(2),
3319 })
3320 );
3321 assert!(!reopened.sync_needed());
3322 drop(reopened);
3323 std::fs::remove_file(path).expect("remove temp database");
3324 }
3325
3326 #[test]
3327 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3328 let path = std::env::temp_dir().join(format!(
3329 "syncular-indexed-column-bump-{}.db",
3330 uuid::Uuid::new_v4()
3331 ));
3332 let old_schema = json!({
3333 "version": 1,
3334 "tables": [
3335 {
3336 "name": "tasks",
3337 "primaryKey": "id",
3338 "columns": [
3339 { "name": "id", "type": "string", "nullable": false },
3340 { "name": "project_id", "type": "string", "nullable": false }
3341 ],
3342 "scopes": [{ "pattern": "project:{project_id}" }]
3343 },
3344 {
3345 "name": "legacy",
3346 "primaryKey": "id",
3347 "columns": [
3348 { "name": "id", "type": "string", "nullable": false },
3349 { "name": "project_id", "type": "string", "nullable": false }
3350 ],
3351 "scopes": [{ "pattern": "project:{project_id}" }]
3352 }
3353 ]
3354 });
3355 {
3356 let mut first = SyncClient::open_path_with_identity(
3357 None,
3358 &old_schema,
3359 ClientLimits::default(),
3360 path.to_str().expect("UTF-8 temp path"),
3361 )
3362 .expect("open old schema");
3363 first
3364 .subscribe(
3365 "legacy-sub".to_owned(),
3366 "legacy".to_owned(),
3367 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3368 None,
3369 )
3370 .expect("persist legacy subscription");
3371 }
3372
3373 let new_schema = json!({
3374 "version": 2,
3375 "tables": [{
3376 "name": "tasks",
3377 "primaryKey": "id",
3378 "columns": [
3379 { "name": "id", "type": "string", "nullable": false },
3380 { "name": "project_id", "type": "string", "nullable": false },
3381 { "name": "facility_membership_id", "type": "string", "nullable": true }
3382 ],
3383 "scopes": [{ "pattern": "project:{project_id}" }],
3384 "indexes": [{
3385 "name": "tasks_by_membership",
3386 "columns": ["project_id", "facility_membership_id"],
3387 "unique": false
3388 }]
3389 }]
3390 });
3391 let upgraded = SyncClient::open_path_with_identity(
3392 None,
3393 &new_schema,
3394 ClientLimits::default(),
3395 path.to_str().expect("UTF-8 temp path"),
3396 )
3397 .expect("open upgraded schema");
3398 let columns = upgraded
3399 .conn
3400 .prepare("PRAGMA table_info(tasks)")
3401 .expect("prepare columns")
3402 .query_map([], |row| row.get::<_, String>(1))
3403 .expect("query columns")
3404 .collect::<Result<Vec<_>, _>>()
3405 .expect("collect columns");
3406 assert!(columns.contains(&"facility_membership_id".to_owned()));
3407 let index_count: i64 = upgraded
3408 .conn
3409 .query_row(
3410 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3411 [],
3412 |row| row.get(0),
3413 )
3414 .expect("query index");
3415 assert_eq!(index_count, 1);
3416 assert!(upgraded.subscription_state("legacy-sub").is_none());
3417 drop(upgraded);
3418 std::fs::remove_file(path).expect("remove temp database");
3419 }
3420
3421 #[test]
3422 fn migrates_pre_envelope_outcome_journal_additively() {
3423 let path = std::env::temp_dir().join(format!(
3424 "syncular-outcome-migration-{}.db",
3425 uuid::Uuid::new_v4()
3426 ));
3427 let conn = Connection::open(&path).expect("open legacy database");
3428 conn.execute_batch(
3429 "CREATE TABLE _syncular_commit_outcomes (
3430 seq INTEGER PRIMARY KEY AUTOINCREMENT,
3431 client_commit_id TEXT NOT NULL UNIQUE,
3432 status TEXT NOT NULL,
3433 recorded_at_ms INTEGER NOT NULL,
3434 results_json TEXT NOT NULL,
3435 resolution TEXT NOT NULL DEFAULT 'active',
3436 resolved_at_ms INTEGER,
3437 replacement_client_commit_id TEXT);",
3438 )
3439 .expect("create legacy outcome journal");
3440 drop(conn);
3441 let schema = json!({
3442 "version": 1,
3443 "tables": [{
3444 "name": "tasks",
3445 "primaryKey": "id",
3446 "columns": [
3447 { "name": "id", "type": "string", "nullable": false },
3448 { "name": "project_id", "type": "string", "nullable": false }
3449 ],
3450 "scopes": [{ "pattern": "project:{project_id}" }]
3451 }]
3452 });
3453 let client = SyncClient::open_path(
3454 "migration-native".to_owned(),
3455 &schema,
3456 ClientLimits::default(),
3457 path.to_str().expect("UTF-8 temp path"),
3458 )
3459 .expect("migrate database");
3460 let has_operations = client
3461 .conn
3462 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3463 .expect("prepare table info")
3464 .query_map([], |row| row.get::<_, String>(1))
3465 .expect("query table info")
3466 .filter_map(Result::ok)
3467 .any(|column| column == "operations_json");
3468 assert!(has_operations);
3469 drop(client);
3470 std::fs::remove_file(path).expect("remove temp database");
3471 }
3472
3473 #[test]
3474 fn durable_conflict_outcome_and_resolution_survive_reopen() {
3475 let path = std::env::temp_dir().join(format!(
3476 "syncular-durable-outcome-{}.db",
3477 uuid::Uuid::new_v4()
3478 ));
3479 let schema = json!({
3480 "version": 1,
3481 "tables": [{
3482 "name": "tasks",
3483 "primaryKey": "id",
3484 "columns": [
3485 { "name": "id", "type": "string", "nullable": false },
3486 { "name": "project_id", "type": "string", "nullable": false }
3487 ],
3488 "scopes": [{ "pattern": "project:{project_id}" }]
3489 }]
3490 });
3491 let conflict = ConflictRecord {
3492 client_commit_id: "losing-commit".to_owned(),
3493 op_index: 0,
3494 table: "tasks".to_owned(),
3495 row_id: "t1".to_owned(),
3496 code: "sync.version_conflict".to_owned(),
3497 message: "stale base version".to_owned(),
3498 server_version: 2,
3499 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3500 conflict_columns: vec!["project_id".to_owned()],
3501 operation: Some(CommitOperation {
3502 table: "tasks".to_owned(),
3503 row_id: "t1".to_owned(),
3504 op: "upsert".to_owned(),
3505 base_version: Some(1),
3506 values: None,
3507 }),
3508 };
3509 let failed_operations = vec![
3510 OutboxOp {
3511 upsert: true,
3512 table: "tasks".to_owned(),
3513 row_id: "t1".to_owned(),
3514 base_version: Some(1),
3515 values: None,
3516 },
3517 OutboxOp {
3518 upsert: true,
3519 table: "tasks".to_owned(),
3520 row_id: "status-event-1".to_owned(),
3521 base_version: Some(0),
3522 values: None,
3523 },
3524 ];
3525
3526 {
3527 let mut first = SyncClient::open_path(
3528 "durable-native".to_owned(),
3529 &schema,
3530 ClientLimits::default(),
3531 path.to_str().expect("UTF-8 temp path"),
3532 )
3533 .expect("first open");
3534 first
3535 .begin_observation("test_outcome")
3536 .expect("begin outcome");
3537 first
3538 .persist_commit_outcome(
3539 "losing-commit",
3540 CommitOutcomeStatus::Conflict,
3541 &[CommitOperationOutcome::Conflict {
3542 conflict: conflict.clone(),
3543 }],
3544 Some(&failed_operations),
3545 )
3546 .expect("persist outcome");
3547 first.conflicts.push(conflict);
3548 first
3549 .finish_observation(
3550 "test_outcome",
3551 ChangeAccumulator {
3552 conflicts: true,
3553 outcomes: true,
3554 ..ChangeAccumulator::default()
3555 },
3556 )
3557 .expect("commit outcome");
3558 }
3559
3560 {
3561 let mut reopened = SyncClient::open_path(
3562 "durable-native".to_owned(),
3563 &schema,
3564 ClientLimits::default(),
3565 path.to_str().expect("UTF-8 temp path"),
3566 )
3567 .expect("reopen");
3568 assert_eq!(reopened.conflicts().len(), 1);
3569 assert_eq!(
3570 reopened.conflicts()[0].conflict_columns,
3571 vec!["project_id".to_owned()],
3572 "conflictColumns survives the journal round trip"
3573 );
3574 let outcome = reopened
3575 .commit_outcome("losing-commit")
3576 .expect("read outcome")
3577 .expect("outcome");
3578 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3579 let operations = outcome.operations.expect("aggregate envelope");
3580 assert_eq!(operations.len(), 2);
3581 assert_eq!(operations[1].row_id, "status-event-1");
3582 let resolved = reopened
3583 .resolve_commit_outcome(ResolveCommitOutcomeInput {
3584 client_commit_id: "losing-commit".to_owned(),
3585 resolution: CommitOutcomeResolution::ResolvedKeepServer,
3586 replacement_client_commit_id: None,
3587 })
3588 .expect("resolve");
3589 assert_eq!(
3590 resolved.resolution,
3591 CommitOutcomeResolution::ResolvedKeepServer
3592 );
3593 assert!(reopened.conflicts().is_empty());
3594 }
3595
3596 let reopened = SyncClient::open_path(
3597 "durable-native".to_owned(),
3598 &schema,
3599 ClientLimits::default(),
3600 path.to_str().expect("UTF-8 temp path"),
3601 )
3602 .expect("second reopen");
3603 assert!(reopened.conflicts().is_empty());
3604 assert_eq!(
3605 reopened
3606 .commit_outcome("losing-commit")
3607 .expect("read outcome")
3608 .expect("outcome")
3609 .resolution,
3610 CommitOutcomeResolution::ResolvedKeepServer
3611 );
3612 drop(reopened);
3613 std::fs::remove_file(path).expect("remove temp database");
3614 }
3615
3616 #[test]
3617 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3618 let path =
3619 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3620 let schema = json!({
3621 "version": 1,
3622 "tables": [{
3623 "name": "tasks",
3624 "primaryKey": "id",
3625 "columns": [
3626 { "name": "id", "type": "string", "nullable": false },
3627 { "name": "project_id", "type": "string", "nullable": false }
3628 ],
3629 "scopes": [{ "pattern": "project:{project_id}" }]
3630 }]
3631 });
3632 let mut client = SyncClient::open_path_with_identity(
3633 Some("sidecar-client".to_owned()),
3634 &schema,
3635 ClientLimits::default(),
3636 path.to_str().expect("UTF-8 temp path"),
3637 )
3638 .expect("open owner");
3639 let base = WindowBase {
3640 table: "tasks".to_owned(),
3641 variable: "project_id".to_owned(),
3642 fixed_scopes: Vec::new(),
3643 params: None,
3644 };
3645 client
3646 .set_window(&base, &["one".to_owned()])
3647 .expect("set window");
3648 client
3649 .mutate(vec![Mutation::Upsert {
3650 table: "tasks".to_owned(),
3651 values: Map::from_iter([
3652 ("id".to_owned(), Value::from("t1")),
3653 ("project_id".to_owned(), Value::from("one")),
3654 ]),
3655 base_version: None,
3656 }])
3657 .expect("local mutate");
3658
3659 let coverage = [WindowCoverage {
3660 base,
3661 units: vec!["one".to_owned(), "missing".to_owned()],
3662 }];
3663 let owner = client
3664 .query_snapshot(
3665 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3666 &[],
3667 &coverage,
3668 None,
3669 )
3670 .expect("owner snapshot");
3671 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3672 let sidecar = reader
3673 .query_snapshot(
3674 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3675 &[],
3676 &coverage,
3677 )
3678 .expect("sidecar snapshot");
3679
3680 assert_eq!(sidecar.revision, owner.revision);
3681 assert_eq!(sidecar.rows, owner.rows);
3682 assert_eq!(
3683 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3684 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3685 );
3686 assert_eq!(sidecar.revision, "2");
3687 assert_eq!(sidecar.rows[0]["id"], "t1");
3688 assert_eq!(sidecar.rows[0]["server_version"], -1);
3689 assert!(!sidecar.coverage.complete);
3690 assert_eq!(sidecar.coverage.pending.len(), 1);
3691 assert_eq!(sidecar.coverage.missing.len(), 1);
3692
3693 drop(reader);
3694 drop(client);
3695 std::fs::remove_file(path).expect("remove temp database");
3696 }
3697
3698 #[test]
3699 fn owned_query_failures_classify_order_bound_and_clear() {
3700 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3701 rusqlite::ffi::Error::new(266),
3702 Some("private SQLite prose".to_owned()),
3703 ));
3704 assert_eq!(io.code, Some("client.storage_io"));
3705 assert_eq!(io.sqlite_code, Some(266));
3706 assert_eq!(io.message, "local SQLite storage I/O failed");
3707 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3708 rusqlite::ffi::Error::new(26),
3709 None,
3710 ));
3711 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
3712
3713 let mut client = client();
3714 let generic = QueryReadFailure::from("private query prose".to_owned());
3715 client.record_query_read(
3716 QueryOwner {
3717 id: "first",
3718 tables: &["tasks", "tasks"],
3719 },
3720 Some(&generic),
3721 );
3722 client.record_query_read(
3723 QueryOwner {
3724 id: "second",
3725 tables: &["tasks"],
3726 },
3727 Some(&generic),
3728 );
3729 client.record_query_read(
3730 QueryOwner {
3731 id: "first",
3732 tables: &["docs"],
3733 },
3734 Some(&io),
3735 );
3736 let failures = &client
3737 .diagnostics_snapshot(&Default::default())
3738 .expect("diagnostics")
3739 .query_failures;
3740 assert_eq!(
3741 failures
3742 .iter()
3743 .map(|entry| entry.id.as_str())
3744 .collect::<Vec<_>>(),
3745 vec!["second", "first"]
3746 );
3747 assert_eq!(failures[1].tables, vec!["docs"]);
3748 assert_eq!(failures[1].code, "client.storage_io");
3749
3750 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
3751 client.record_query_read(
3752 QueryOwner {
3753 id: &format!("query-{index}"),
3754 tables: &["tasks"],
3755 },
3756 Some(&generic),
3757 );
3758 }
3759 let failures = &client
3760 .diagnostics_snapshot(&Default::default())
3761 .expect("bounded diagnostics")
3762 .query_failures;
3763 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
3764 assert_eq!(failures.last().expect("last failure").id, "query-256");
3765
3766 client
3767 .query_snapshot(
3768 "SELECT id FROM tasks",
3769 &[],
3770 &[],
3771 Some(QueryOwner {
3772 id: "query-256",
3773 tables: &["tasks"],
3774 }),
3775 )
3776 .expect("successful owned read");
3777 assert!(client
3778 .diagnostics_snapshot(&Default::default())
3779 .expect("cleared diagnostics")
3780 .query_failures
3781 .iter()
3782 .all(|entry| entry.id != "query-256"));
3783 }
3784
3785 #[test]
3786 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3787 let mut client = client();
3788 let image = Connection::open_in_memory().unwrap();
3789 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3790 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3791 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3792 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3793 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3794 let table = client.schema.table("tasks").unwrap().clone();
3795 let scopes = vec![("project_id".into(), vec!["p1".into()])];
3796 assert!(client
3797 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3798 .is_err());
3799 assert!(client.conn.is_autocommit());
3800 assert_eq!(
3801 client
3802 .query("SELECT count(*) AS n FROM tasks", &[])
3803 .unwrap()[0]["n"],
3804 1024
3805 );
3806 assert_eq!(client.drain_change_batches().len(), 1);
3807 image
3808 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3809 .unwrap();
3810 assert!(matches!(
3811 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3812 Ok(2050)
3813 ));
3814 assert_eq!(
3815 client
3816 .query("SELECT count(*) AS n FROM tasks", &[])
3817 .unwrap()[0]["n"],
3818 2050
3819 );
3820 assert_eq!(client.drain_change_batches().len(), 3);
3821 }
3822
3823 #[test]
3824 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3825 let mut client = client();
3826 let base = WindowBase {
3827 table: "tasks".into(),
3828 variable: "project_id".into(),
3829 fixed_scopes: Vec::new(),
3830 params: None,
3831 };
3832 client
3833 .set_window(&base, &["p1".into(), "p2".into()])
3834 .unwrap();
3835 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3836 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3837 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3838 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3839 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3840 client.drain_change_batches();
3841 assert!(client.set_window(&base, &["p2".into()]).is_err());
3842 assert!(client.conn.is_autocommit());
3843 assert_eq!(
3844 client
3845 .query("SELECT count(*) AS n FROM tasks", &[])
3846 .unwrap()[0]["n"],
3847 1027
3848 );
3849 assert_eq!(client.window_state(&base).units, vec!["p2"]);
3850 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
3851 assert_eq!(client.drain_change_batches().len(), 1);
3852 client
3853 .conn
3854 .execute_batch("DROP TRIGGER interrupt_evict")
3855 .unwrap();
3856 client.drain_pending_evictions().unwrap();
3857 assert!(client.load_pending_evictions().unwrap().is_empty());
3858 assert_eq!(
3859 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3860 "held"
3861 );
3862 assert_eq!(client.drain_change_batches().len(), 2);
3863 }
3864
3865 #[test]
3866 fn eviction_marker_failures_roll_back_window_changes() {
3867 let mut client = client();
3868 let base = WindowBase {
3869 table: "tasks".into(),
3870 variable: "project_id".into(),
3871 fixed_scopes: Vec::new(),
3872 params: None,
3873 };
3874 client.set_window(&base, &["p1".into()]).unwrap();
3875 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3876 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3877 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3878 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3879 let revision = client.local_revision();
3880 assert!(client.set_window(&base, &[]).is_err());
3881 assert!(client.conn.is_autocommit());
3882 assert_eq!(client.local_revision(), revision);
3883 assert_eq!(
3884 client
3885 .query("SELECT count(*) AS n FROM tasks", &[])
3886 .unwrap()[0]["n"],
3887 2050
3888 );
3889 assert_eq!(client.window_state(&base).units, vec!["p1"]);
3890 client.conn.execute_batch("DROP TRIGGER fail_marker;
3891 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3892 assert!(client.set_window(&base, &[]).is_err());
3893 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
3894 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3895 assert!(client.drain_pending_evictions().is_err());
3896 assert!(client.conn.is_autocommit());
3897 assert_eq!(
3898 client
3899 .query("SELECT count(*) AS n FROM tasks", &[])
3900 .unwrap()[0]["n"],
3901 2
3902 );
3903 assert!(client.set_window(&base, &["p1".into()]).is_err());
3904 assert!(client.conn.is_autocommit());
3905 assert!(client.window_state(&base).units.is_empty());
3906 assert!(client.subs.is_empty());
3907 assert_eq!(
3908 client
3909 .query(
3910 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
3911 &[]
3912 )
3913 .unwrap()[0]["n"],
3914 1
3915 );
3916 client
3917 .conn
3918 .execute_batch("DROP TRIGGER fail_marker")
3919 .unwrap();
3920 client.set_window(&base, &["p1".into()]).unwrap();
3921 assert_eq!(client.window_state(&base).units, vec!["p1"]);
3922 }
3923
3924 #[test]
3925 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
3926 let mut client = client();
3927 client
3928 .conn
3929 .execute(
3930 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
3931 [],
3932 )
3933 .unwrap();
3934 assert_eq!(
3935 client.drain_pending_evictions().unwrap_err(),
3936 "sync.local_corrupt: invalid pending eviction scopes"
3937 );
3938 assert!(client.conn.is_autocommit());
3939 assert!(client.enqueue_startup_sync_if_needed().is_err());
3940 }
3941
3942 #[test]
3943 fn image_chunks_reject_null_primary_keys() {
3944 let mut client = client();
3945 let image = Connection::open_in_memory().unwrap();
3946 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3947 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
3948 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
3949 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3950 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
3951 let table = client.schema.table("tasks").unwrap().clone();
3952 assert!(client
3953 .apply_sqlite_image(
3954 &image,
3955 &table,
3956 true,
3957 &[("project_id".into(), vec!["p1".into()])],
3958 1025,
3959 7,
3960 "digest"
3961 )
3962 .is_err());
3963 assert!(client.conn.is_autocommit());
3964 assert_eq!(
3965 client
3966 .query("SELECT count(*) AS n FROM tasks", &[])
3967 .unwrap()[0]["n"],
3968 0
3969 );
3970 }
3971
3972 #[test]
3973 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
3974 for images in [false, true] {
3975 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
3976 "name":name,"primaryKey":"id","columns":[
3977 {"name":"id","type":"string","nullable":false},
3978 {"name":"project_id","type":"string","nullable":false},
3979 {"name":"title","type":"string","nullable":false}],
3980 "scopes":[{"pattern":"project:{project_id}"}],
3981 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
3982 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
3983 })))});
3984 let mut actual =
3985 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
3986 let mut reference =
3987 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
3988 let scopes = vec![("project_id".into(), vec!["p1".into()])];
3989 for client in [&mut actual, &mut reference] {
3990 client
3991 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
3992 .unwrap();
3993 client.subs[0].effective = Some(scopes.clone());
3994 for (table, id, title) in [
3995 ("tasks", "a", "original"),
3996 ("tasks", "b", "occupied"),
3997 ("tasks", "c", "deleted"),
3998 ("notes", "n", "untouched"),
3999 ] {
4000 client
4001 .write_base_row(
4002 table,
4003 &vec![
4004 Some(ColumnValue::String(id.into())),
4005 Some(ColumnValue::String("p1".into())),
4006 Some(ColumnValue::String(title.into())),
4007 ],
4008 1,
4009 )
4010 .unwrap();
4011 }
4012 client.rebuild_overlay_if_dirty();
4013 client
4014 .mutate(vec![
4015 Mutation::Upsert {
4016 table: "tasks".into(),
4017 values: Map::from_iter([
4018 ("id".into(), Value::from("a")),
4019 ("project_id".into(), Value::from("p1")),
4020 ("title".into(), Value::from("available")),
4021 ]),
4022 base_version: None,
4023 },
4024 Mutation::Delete {
4025 table: "tasks".into(),
4026 row_id: "c".into(),
4027 base_version: None,
4028 },
4029 Mutation::Upsert {
4030 table: "tasks".into(),
4031 values: Map::from_iter([
4032 ("id".into(), Value::from("local")),
4033 ("project_id".into(), Value::from("p1")),
4034 ("title".into(), Value::from("local value")),
4035 ]),
4036 base_version: None,
4037 },
4038 Mutation::Upsert {
4039 table: "notes".into(),
4040 values: Map::from_iter([
4041 ("id".into(), Value::from("n")),
4042 ("project_id".into(), Value::from("p1")),
4043 ("title".into(), Value::from("pending note")),
4044 ]),
4045 base_version: None,
4046 },
4047 ])
4048 .unwrap();
4049 }
4050 let pending = actual.pending_commit_ids();
4051 actual.overlay_rebuild_count.set(0);
4052 for (id, title) in [
4053 ("b", "available"),
4054 ("b", "occupied"),
4055 ("a", "server original"),
4056 ("d", "local value"),
4057 ("d", "released"),
4058 ("c", "redelivered"),
4059 ] {
4060 let table = actual.schema.table("tasks").unwrap().clone();
4061 let values = vec![
4062 Some(ColumnValue::String(id.into())),
4063 Some(ColumnValue::String("p1".into())),
4064 Some(ColumnValue::String(title.into())),
4065 ];
4066 if images {
4067 let image = Connection::open_in_memory().unwrap();
4068 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4069 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4070 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4071 image
4072 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
4073 .unwrap();
4074 assert!(matches!(
4075 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
4076 Ok(1)
4077 ));
4078 } else {
4079 let segment = RowsSegment {
4080 table: "tasks".into(),
4081 schema_version: 1,
4082 columns: table.wire_columns.clone(),
4083 blocks: vec![vec![ssp2::segment::SegmentRow {
4084 server_version: 2,
4085 values: values.clone(),
4086 }]],
4087 };
4088 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4089 }
4090 reference.write_base_row("tasks", &values, 2).unwrap();
4091 reference.rebuild_overlay();
4092 for table in ["tasks", "notes"] {
4093 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")] {
4094 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4095 }
4096 }
4097 assert_eq!(actual.pending_commit_ids(), pending);
4098 }
4099 assert_eq!(
4100 actual.overlay_rebuild_count.get(),
4101 0,
4102 "each chunk must leave unrelated tables alone"
4103 );
4104 let before = actual
4105 .query("SELECT * FROM tasks ORDER BY id", &[])
4106 .unwrap();
4107 let fts_before = actual
4108 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4109 .unwrap();
4110 let revision = actual.local_revision();
4111 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4112 let table = actual.schema.table("tasks").unwrap().clone();
4113 let segment = RowsSegment {
4114 table: "tasks".into(),
4115 schema_version: 1,
4116 columns: table.wire_columns.clone(),
4117 blocks: vec![vec![ssp2::segment::SegmentRow {
4118 server_version: 3,
4119 values: vec![
4120 Some(ColumnValue::String("b".into())),
4121 Some(ColumnValue::String("p1".into())),
4122 Some(ColumnValue::String("fresh".into())),
4123 ],
4124 }]],
4125 };
4126 assert!(actual.apply_segment(0, &segment, false).is_err());
4127 assert!(actual.conn.is_autocommit());
4128 assert_eq!(
4129 actual
4130 .query("SELECT * FROM tasks ORDER BY id", &[])
4131 .unwrap(),
4132 before
4133 );
4134 assert_eq!(
4135 actual
4136 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4137 .unwrap(),
4138 fts_before
4139 );
4140 assert_eq!(actual.local_revision(), revision);
4141 assert_eq!(actual.pending_commit_ids(), pending);
4142 }
4143 }
4144
4145 #[test]
4146 fn import_reconciliation_preserves_typed_pending_primary_keys() {
4147 for (kind, a, b) in [
4148 ("string", json!("a"), json!("b")),
4149 ("integer", json!(1), json!(2)),
4150 ("boolean", json!(true), json!(false)),
4151 ("json", json!("1"), json!("2")),
4152 ] {
4153 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}]}]});
4154 for images in [false, true] {
4155 let mut client =
4156 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
4157 let table = client.schema.table("tasks").unwrap().clone();
4158 client
4159 .subscribe("tasks".into(), "tasks".into(), vec![], None)
4160 .unwrap();
4161 for (id, title) in [(&a, "original"), (&b, "occupied")] {
4162 client
4163 .write_base_row(
4164 "tasks",
4165 &vec![
4166 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
4167 Some(ColumnValue::String(title.into())),
4168 ],
4169 1,
4170 )
4171 .unwrap();
4172 }
4173 client.rebuild_overlay_if_dirty();
4174 client
4175 .mutate(vec![Mutation::Upsert {
4176 table: "tasks".into(),
4177 values: Map::from_iter([
4178 ("id".into(), a.clone()),
4179 ("title".into(), json!("available")),
4180 ]),
4181 base_version: None,
4182 }])
4183 .unwrap();
4184 client.overlay_rebuild_count.set(0);
4185 for (title, expected) in [("available", "original"), ("occupied", "available")] {
4186 let values = vec![
4187 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
4188 Some(ColumnValue::String(title.into())),
4189 ];
4190 if images {
4191 let image = Connection::open_in_memory().unwrap();
4192 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4193 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4194 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4195 image
4196 .execute(
4197 "INSERT INTO tasks VALUES (?1,?2,2)",
4198 rusqlite::params![RowParam::Cell(&values[0]), title],
4199 )
4200 .unwrap();
4201 assert!(
4202 matches!(
4203 client.apply_sqlite_image(
4204 &image,
4205 &table,
4206 false,
4207 &[],
4208 1,
4209 7,
4210 "digest"
4211 ),
4212 Ok(1)
4213 ),
4214 "{kind}, images={images}"
4215 );
4216 } else {
4217 let segment = RowsSegment {
4218 table: "tasks".into(),
4219 schema_version: 1,
4220 columns: table.wire_columns.clone(),
4221 blocks: vec![vec![ssp2::segment::SegmentRow {
4222 server_version: 2,
4223 values,
4224 }]],
4225 };
4226 assert!(
4227 matches!(client.apply_segment(0, &segment, false), Ok(1)),
4228 "{kind}"
4229 );
4230 }
4231 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
4232 let actual: String = client
4233 .conn
4234 .query_row(
4235 "SELECT title FROM tasks WHERE id=?1",
4236 [RowParam::Cell(&key)],
4237 |row| row.get(0),
4238 )
4239 .unwrap();
4240 assert_eq!(actual, expected, "{kind}, images={images}");
4241 }
4242 assert_eq!(client.overlay_rebuild_count.get(), 0);
4243 }
4244 }
4245 }
4246
4247 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
4248 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4249 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4250 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4251 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4252 })))});
4253 let mut client =
4254 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4255 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();
4256 client.rebuild_overlay();
4257 client
4258 .mutate(vec![Mutation::Upsert {
4259 table: "tasks".into(),
4260 values: Map::from_iter([
4261 ("id".into(), json!("offline")),
4262 ("project_id".into(), json!("p1")),
4263 ("title".into(), json!("pending edit")),
4264 ]),
4265 base_version: None,
4266 }])
4267 .unwrap();
4268 let image = Connection::open_in_memory().unwrap();
4269 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4270 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4271 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();
4272 image
4273 .execute(
4274 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4275 [rows],
4276 )
4277 .unwrap();
4278 client.overlay_rebuild_count.set(0);
4279 let table = client.schema.table("tasks").unwrap().clone();
4280 (client, image, table)
4281 }
4282
4283 #[test]
4284 fn large_unique_import_copies_no_unrelated_tables() {
4285 let (mut client, image, table) = unique_import_fixture(10240);
4286 let pending = client.pending_commit_ids();
4287 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4288 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4289 assert!(matches!(
4290 client.apply_sqlite_image(
4291 &image,
4292 &table,
4293 true,
4294 &[("project_id".into(), vec!["p1".into()])],
4295 10240,
4296 7,
4297 "digest"
4298 ),
4299 Ok(10240)
4300 ));
4301 assert_eq!(client.overlay_rebuild_count.get(), 0);
4302 assert_eq!(client.pending_commit_ids(), pending);
4303 assert_eq!(
4304 client
4305 .query("SELECT count(*) AS n FROM tasks", &[])
4306 .unwrap()[0]["n"],
4307 10241
4308 );
4309 assert_eq!(
4310 client
4311 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4312 .unwrap()[0]["n"],
4313 10241
4314 );
4315 assert_eq!(
4316 client
4317 .query("SELECT count(*) AS n FROM notes_fts", &[])
4318 .unwrap()[0]["n"],
4319 8192
4320 );
4321 }
4322
4323 #[test]
4324 #[ignore = "manual scaling benchmark; see bench/README.md"]
4325 fn benchmark_unique_image_import() {
4326 let mut samples = Vec::new();
4327 for rows in [10240, 51200, 102400] {
4328 for trial in 0..4 {
4329 let (mut client, image, table) = unique_import_fixture(rows);
4330 let start = std::time::Instant::now();
4331 assert!(
4332 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)
4333 );
4334 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4335 assert_eq!(
4336 client
4337 .query("SELECT count(*) AS n FROM tasks", &[])
4338 .unwrap()[0]["n"],
4339 rows + 1
4340 );
4341 assert_eq!(
4342 client
4343 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4344 .unwrap()[0]["n"],
4345 rows + 1
4346 );
4347 assert_eq!(
4348 client
4349 .query("SELECT count(*) AS n FROM notes_fts", &[])
4350 .unwrap()[0]["n"],
4351 8192
4352 );
4353 assert_eq!(client.pending_commit_ids().len(), 1);
4354 if trial > 0 {
4355 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4356 }
4357 }
4358 }
4359 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});
4360 let output = serde_json::to_string_pretty(&artifact).unwrap();
4361 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4362 use std::io::Write;
4363 let mut file = std::fs::OpenOptions::new()
4364 .write(true)
4365 .create_new(true)
4366 .open(path)
4367 .unwrap();
4368 file.write_all(output.as_bytes()).unwrap();
4369 } else {
4370 println!("{output}");
4371 }
4372 }
4373
4374 #[test]
4375 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4376 let schema = json!({
4377 "version": 1,
4378 "tables": [{
4379 "name": "catalogue_codes",
4380 "primaryKey": "id",
4381 "columns": [
4382 { "name": "id", "type": "string", "nullable": false },
4383 { "name": "release_id", "type": "string", "nullable": false },
4384 { "name": "code", "type": "string", "nullable": false },
4385 { "name": "title", "type": "string", "nullable": false }
4386 ],
4387 "scopes": [{ "pattern": "release:{release_id}" }],
4388 "ftsIndexes": [{
4389 "name": "catalogue_codes_fts",
4390 "columns": ["code", "title"],
4391 "tokenize": "unicode61 remove_diacritics 2"
4392 }]
4393 }]
4394 });
4395 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4396 .expect("FTS5 client");
4397 let insert_trigger: String = client
4398 .conn
4399 .query_row(
4400 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4401 [],
4402 |row| row.get(0),
4403 )
4404 .expect("insert trigger");
4405 let replace_guard: String = client
4406 .conn
4407 .query_row(
4408 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4409 [],
4410 |row| row.get(0),
4411 )
4412 .expect("replace guard");
4413 assert!(!insert_trigger.contains("DELETE FROM"));
4414 assert!(replace_guard.contains("BEFORE INSERT"));
4415 assert!(replace_guard.contains("WHEN EXISTS"));
4416 let search = |client: &SyncClient, query: &str| {
4417 client
4418 .query(
4419 "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",
4420 &[Value::from(query)],
4421 )
4422 .expect("FTS query")
4423 };
4424
4425 client
4426 .mutate(vec![Mutation::Upsert {
4427 table: "catalogue_codes".to_owned(),
4428 values: Map::from_iter([
4429 ("id".to_owned(), Value::from("c1")),
4430 ("release_id".to_owned(), Value::from("r1")),
4431 ("code".to_owned(), Value::from("A01")),
4432 ("title".to_owned(), Value::from("Cholera")),
4433 ]),
4434 base_version: None,
4435 }])
4436 .expect("insert code");
4437 assert_eq!(search(&client, "cholera").len(), 1);
4438 let prior = client
4439 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4440 .unwrap();
4441 client
4442 .conn
4443 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4444 .unwrap();
4445 client.create_synced_tables().unwrap();
4446 assert_eq!(
4447 client
4448 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4449 .unwrap(),
4450 prior
4451 );
4452 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();
4453 let rendered = serde_json::to_string(&plan).unwrap();
4454 assert!(rendered.contains("COVERING INDEX"));
4455 assert!(rendered.contains("INDEX 0:="));
4456 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4457 assert_eq!(search(&client, "cholera").len(), 1);
4458
4459 client
4460 .mutate(vec![Mutation::Upsert {
4461 table: "catalogue_codes".to_owned(),
4462 values: Map::from_iter([
4463 ("id".to_owned(), Value::from("c1")),
4464 ("release_id".to_owned(), Value::from("r1")),
4465 ("code".to_owned(), Value::from("A01")),
4466 ("title".to_owned(), Value::from("Enteric infection")),
4467 ]),
4468 base_version: None,
4469 }])
4470 .expect("update code");
4471 assert!(search(&client, "cholera").is_empty());
4472 assert_eq!(search(&client, "enteric").len(), 1);
4473
4474 client
4475 .mutate(vec![Mutation::Delete {
4476 table: "catalogue_codes".to_owned(),
4477 row_id: "c1".to_owned(),
4478 base_version: None,
4479 }])
4480 .expect("delete code");
4481 assert!(search(&client, "enteric").is_empty());
4482 }
4483
4484 #[test]
4485 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4486 let schema = json!({
4487 "version": 1,
4488 "tables": [{
4489 "name": "patient_notes",
4490 "primaryKey": "id",
4491 "columns": [
4492 { "name": "id", "type": "string", "nullable": false },
4493 { "name": "practice_id", "type": "string", "nullable": false },
4494 { "name": "encryption_key_id", "type": "string", "nullable": false },
4495 { "name": "title", "type": "string", "nullable": false }
4496 ],
4497 "scopes": [{ "pattern": "practice:{practice_id}" }],
4498 "ftsIndexes": [{
4499 "name": "patient_notes_fts",
4500 "columns": ["title"],
4501 "tokenize": "unicode61 remove_diacritics 2"
4502 }]
4503 }]
4504 });
4505 let mut client = SyncClient::new(
4506 "local-purge-test".to_owned(),
4507 &schema,
4508 ClientLimits::default(),
4509 )
4510 .expect("local purge client");
4511 client
4512 .conn
4513 .execute(
4514 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4515 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4516 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4517 [],
4518 )
4519 .expect("seed base rows");
4520 client.overlay_dirty.set(true);
4521 client.rebuild_overlay_if_dirty();
4522
4523 let note = |id: &str, key_id: &str, title: &str| {
4524 Map::from_iter([
4525 ("id".to_owned(), Value::from(id)),
4526 ("practice_id".to_owned(), Value::from("practice-1")),
4527 ("encryption_key_id".to_owned(), Value::from(key_id)),
4528 ("title".to_owned(), Value::from(title)),
4529 ])
4530 };
4531 let doomed = client
4532 .mutate(vec![
4533 Mutation::Upsert {
4534 table: "patient_notes".to_owned(),
4535 values: note("target", "key-revoked", "Target changed"),
4536 base_version: None,
4537 },
4538 Mutation::Upsert {
4539 table: "patient_notes".to_owned(),
4540 values: note("unrelated", "key-held", "Unrelated changed"),
4541 base_version: None,
4542 },
4543 ])
4544 .expect("doomed commit");
4545 let kept = client
4546 .mutate(vec![Mutation::Upsert {
4547 table: "patient_notes".to_owned(),
4548 values: note("kept", "key-held", "Kept optimistic"),
4549 base_version: None,
4550 }])
4551 .expect("kept commit");
4552 client.drain_change_batches();
4553
4554 let input = LocalDataPurgeInput {
4555 purge_id: "purge-001".to_owned(),
4556 targets: vec![LocalDataPurgeTarget {
4557 table: "patient_notes".to_owned(),
4558 selectors: BTreeMap::from([(
4559 "encryption_key_id".to_owned(),
4560 vec!["key-revoked".to_owned()],
4561 )]),
4562 }],
4563 };
4564 assert_eq!(
4565 client.purge_local_data(&input).expect("apply purge"),
4566 LocalDataPurgeResult {
4567 already_applied: false,
4568 purged_rows: 1,
4569 dropped_commits: 1,
4570 }
4571 );
4572 let rows = client
4573 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
4574 .expect("visible rows");
4575 assert_eq!(rows.len(), 2);
4576 assert_eq!(rows[0]["id"], "kept");
4577 assert_eq!(rows[1]["id"], "unrelated");
4578 assert_eq!(rows[1]["title"], "Unrelated original");
4579 let fts = client
4580 .query(
4581 "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'",
4582 &[],
4583 )
4584 .expect("fts query");
4585 assert!(fts.is_empty());
4586 assert_eq!(client.outbox.len(), 1);
4587 assert_eq!(client.outbox[0].client_commit_id, kept);
4588 let outcome = client
4589 .commit_outcome(&doomed)
4590 .expect("read doomed outcome")
4591 .expect("doomed outcome");
4592 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
4593 match &outcome.results[0] {
4594 CommitOperationOutcome::Error { rejection } => {
4595 assert_eq!(rejection.code, "client.local_data_purged");
4596 assert_eq!(rejection.client_commit_id, doomed);
4597 }
4598 other => panic!("expected local purge rejection, got {other:?}"),
4599 }
4600 assert_eq!(client.drain_change_batches().len(), 1);
4601 assert_eq!(
4602 client.purge_local_data(&input).expect("retry purge"),
4603 LocalDataPurgeResult {
4604 already_applied: true,
4605 purged_rows: 0,
4606 dropped_commits: 0,
4607 }
4608 );
4609 let conflicting = LocalDataPurgeInput {
4610 purge_id: input.purge_id.clone(),
4611 targets: vec![LocalDataPurgeTarget {
4612 table: "patient_notes".to_owned(),
4613 selectors: BTreeMap::from([(
4614 "encryption_key_id".to_owned(),
4615 vec!["key-held".to_owned()],
4616 )]),
4617 }],
4618 };
4619 assert!(client
4620 .purge_local_data(&conflicting)
4621 .expect_err("id collision must fail")
4622 .contains("already used with a different plan"));
4623 }
4624}
4625
4626impl SubState {
4627 fn name(self) -> &'static str {
4628 match self {
4629 SubState::Active => "active",
4630 SubState::Revoked => "revoked",
4631 SubState::Failed => "failed",
4632 }
4633 }
4634
4635 fn parse(value: &str) -> Self {
4636 match value {
4637 "revoked" => Self::Revoked,
4638 "failed" => Self::Failed,
4639 _ => Self::Active,
4640 }
4641 }
4642}
4643
4644#[derive(Debug, Clone)]
4645struct Subscription {
4646 id: String,
4647 table: String,
4648 requested: Vec<(String, Vec<String>)>,
4649 params: Option<String>,
4650 cursor: i64,
4651 bootstrap_state: Option<String>,
4653 state: SubState,
4654 reason_code: Option<String>,
4655 effective: Option<Vec<(String, Vec<String>)>>,
4658 synced_once: bool,
4659}
4660
4661#[derive(Debug, Clone)]
4662struct OutboxOp {
4663 upsert: bool,
4664 table: String,
4665 row_id: String,
4666 base_version: Option<i64>,
4667 values: Option<Map<String, Value>>,
4671}
4672
4673impl From<&OutboxOp> for CommitOperation {
4674 fn from(operation: &OutboxOp) -> Self {
4675 Self {
4676 table: operation.table.clone(),
4677 row_id: operation.row_id.clone(),
4678 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4679 base_version: operation.base_version,
4680 values: operation.values.clone(),
4681 }
4682 }
4683}
4684
4685#[derive(Debug, Clone)]
4686struct OutboxCommit {
4687 client_commit_id: String,
4688 ops: Vec<OutboxOp>,
4689}
4690
4691fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
4695 commit
4696 .ops
4697 .iter()
4698 .filter(|operation| operation.upsert)
4699 .map(|operation| {
4700 (
4701 operation.table.as_str(),
4702 operation
4703 .values
4704 .as_ref()
4705 .map(|values| values.keys().map(String::as_str).collect())
4706 .unwrap_or_default(),
4707 )
4708 })
4709 .collect()
4710}
4711
4712#[derive(Debug, Clone)]
4713struct CompiledLocalDataPurgeTarget {
4714 table: String,
4715 selectors: Vec<(String, Vec<String>)>,
4716}
4717
4718struct StoredCommitOutcomeRow {
4719 sequence: i64,
4720 client_commit_id: String,
4721 status: String,
4722 recorded_at_ms: i64,
4723 results_json: String,
4724 operations_json: Option<String>,
4725 resolution: String,
4726 resolved_at_ms: Option<i64>,
4727 replacement_client_commit_id: Option<String>,
4728}
4729
4730enum SectionError {
4733 FailClosed,
4734 Abort(String, String),
4735}
4736
4737struct RequestMeta {
4738 pushed_ids: Vec<String>,
4739 fresh: Vec<(String, bool)>,
4742 accept: u8,
4743 deferred_commits: usize,
4747}
4748
4749#[derive(Default)]
4750struct ChangeAccumulator {
4751 tables: BTreeMap<String, Option<BTreeSet<String>>>,
4753 windows: BTreeMap<(String, String), BTreeSet<String>>,
4754 status: bool,
4755 conflicts: bool,
4756 rejections: bool,
4757 outcomes: bool,
4758}
4759
4760impl ChangeAccumulator {
4761 fn table(&mut self, table: &str) {
4762 self.tables.insert(table.to_owned(), None);
4763 }
4764
4765 fn scope(&mut self, table: &str, key: String) {
4766 match self.tables.get_mut(table) {
4767 Some(None) => {}
4768 Some(Some(keys)) => {
4769 keys.insert(key);
4770 }
4771 None => {
4772 self.tables
4773 .insert(table.to_owned(), Some(BTreeSet::from([key])));
4774 }
4775 }
4776 }
4777
4778 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4779 self.windows
4780 .entry((base_key.to_owned(), table.to_owned()))
4781 .or_default()
4782 .insert(unit.to_owned());
4783 }
4784
4785 fn touched(&self) -> bool {
4786 !self.tables.is_empty()
4787 || !self.windows.is_empty()
4788 || self.status
4789 || self.conflicts
4790 || self.rejections
4791 || self.outcomes
4792 }
4793}
4794
4795const PUSH_OPS_PER_REQUEST: usize = 500;
4801const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4802const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4803const MAX_LOCAL_PURGE_VALUES: usize = 128;
4804const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4805pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4806
4807pub struct SyncClient {
4808 #[cfg(feature = "bench-internals")]
4809 benchmark_phases: Recorder,
4810 conn: Connection,
4811 schema: ClientSchema,
4812 client_id: String,
4813 limits: ClientLimits,
4814 subs: Vec<Subscription>,
4815 outbox: Vec<OutboxCommit>,
4816 conflicts: Vec<ConflictRecord>,
4817 rejections: Vec<RejectionRecord>,
4818 schema_floor: Option<SchemaFloor>,
4819 lease_state: Option<LeaseState>,
4821 stopped: bool,
4823 upgrading: bool,
4825 sync_needed: bool,
4827 realtime_policy: RealtimePolicy,
4830 realtime_state: RealtimeState,
4831 realtime_reason_code: Option<String>,
4832 realtime_retry_delay_ms: Option<u64>,
4833 presence: HashMap<String, HashMap<String, PresencePeer>>,
4835 now_ms: Option<i64>,
4838 encryption: crate::values::EncryptionConfig,
4842 security_preflight: bool,
4845 previous_version: Option<PreviousVersionContextConfig>,
4849 insert_sql: RefCell<HashMap<String, String>>,
4855 overlay_dirty: Cell<bool>,
4859 #[cfg(test)]
4862 overlay_rebuild_count: Cell<usize>,
4863 #[cfg(test)]
4866 outcome_prune_count: Cell<usize>,
4867 progress: ProgressObserver,
4869 change_queue: VecDeque<ClientChangeBatch>,
4870 sync_intent_queue: VecDeque<SyncIntent>,
4871 retry_delay_ms: u64,
4873 last_round: Option<DiagnosticLastRound>,
4874 last_change: Option<DiagnosticLastChange>,
4875 query_failures: Vec<DiagnosticQueryFailure>,
4877}
4878
4879pub(crate) fn quote_ident(name: &str) -> String {
4880 format!("\"{}\"", name.replace('"', "\"\""))
4881}
4882pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
4886 conn.query_row(
4887 "SELECT value FROM _syncular_meta WHERE key = ?1",
4888 rusqlite::params![key],
4889 |row| row.get::<_, String>(0),
4890 )
4891 .ok()
4892}
4893
4894pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
4895 let _ = conn.execute(
4896 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
4897 rusqlite::params![key, value],
4898 );
4899}
4900
4901pub(crate) fn meta_delete(conn: &Connection, key: &str) {
4902 let _ = conn.execute(
4903 "DELETE FROM _syncular_meta WHERE key = ?1",
4904 rusqlite::params![key],
4905 );
4906}
4907
4908fn row_id_predicate(table: &TableSchema) -> String {
4910 let key = quote_ident(&table.primary_key);
4911 let text_match = format!("CAST({key} AS TEXT) = ?1");
4912 match table.columns[table.pk_index].ty {
4913 ColumnType::String | ColumnType::Json => {
4914 format!("{key} = ?1 AND {text_match}")
4915 }
4916 ColumnType::Integer | ColumnType::Boolean => {
4920 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
4921 }
4922 _ => text_match,
4925 }
4926}
4927
4928fn is_local_operation_code_like(value: &str) -> bool {
4929 let bytes = value.as_bytes();
4930 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
4931 && bytes
4932 .iter()
4933 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
4934}
4935
4936fn base_table(name: &str) -> String {
4937 quote_ident(&format!("_syncular_base_{name}"))
4938}
4939
4940type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
4945
4946fn window_base_key(base: &WindowBase) -> String {
4947 format!(
4948 "{}\0{}\0{}",
4949 base.table,
4950 base.variable,
4951 canonical_scope_json(&base.fixed_scopes)
4952 )
4953}
4954
4955fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
4957 let mut scopes = base.fixed_scopes.clone();
4958 scopes.retain(|(k, _)| k != &base.variable);
4959 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
4960 scopes
4961}
4962
4963fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
4967 let canonical = canonical_scope_json(&unit_scopes(base, unit));
4968 let digest = Sha256::digest(canonical.as_bytes());
4969 let hex = bytes_to_hex(&digest);
4970 format!("w:{}:{}", base.table, &hex[..16])
4971}
4972
4973fn visible_table(name: &str) -> String {
4974 quote_ident(name)
4975}
4976
4977const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
4978
4979fn is_synced_table_name(name: &str) -> bool {
4982 if name.starts_with("sqlite_") {
4983 return false;
4984 }
4985 if name.starts_with("_syncular_base_") {
4986 return true; }
4988 !name.starts_with("_syncular_")
4990}
4991
4992fn blob_id_for(bytes: &[u8]) -> String {
4994 let digest = Sha256::digest(bytes);
4995 format!("sha256:{}", bytes_to_hex(&digest))
4996}
4997
4998enum RowParam<'a> {
5002 Cell(&'a Option<ColumnValue>),
5003 Version(i64),
5004}
5005
5006impl rusqlite::ToSql for RowParam<'_> {
5007 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
5008 Ok(match self {
5009 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
5010 RowParam::Cell(cell) => match cell {
5011 None => ToSqlOutput::Owned(SqlValue::Null),
5012 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
5013 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
5014 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
5015 Some(ColumnValue::Boolean(b)) => {
5016 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
5017 }
5018 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
5019 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
5020 }
5021 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
5023 ToSqlOutput::Borrowed(ValueRef::Blob(b))
5024 }
5025 },
5026 })
5027 }
5028}
5029
5030fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
5032 match param.to_sql().map_err(|error| error.to_string())? {
5033 ToSqlOutput::Owned(value) => Ok(value),
5034 ToSqlOutput::Borrowed(value) => {
5035 SqlValue::try_from(value).map_err(|error| error.to_string())
5036 }
5037 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
5038 }
5039}
5040
5041fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
5049 use ssp2::segment::ColumnType;
5050 let mismatch = || {
5051 Err(format!(
5052 "image column {:?} holds a value of the wrong type",
5053 column.name
5054 ))
5055 };
5056 match value {
5057 ValueRef::Null => {
5058 if !column.nullable {
5059 return Err(format!(
5060 "image column {:?} is NULL but not nullable",
5061 column.name
5062 ));
5063 }
5064 Ok(ToSqlOutput::Owned(SqlValue::Null))
5065 }
5066 ValueRef::Integer(i) => match column.ty {
5067 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
5068 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
5069 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
5070 _ => mismatch(),
5071 },
5072 ValueRef::Real(_) => match column.ty {
5073 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
5074 _ => mismatch(),
5075 },
5076 ValueRef::Text(t) => {
5077 std::str::from_utf8(t)
5078 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
5079 match column.ty {
5080 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
5081 Ok(ToSqlOutput::Borrowed(value))
5082 }
5083 _ => mismatch(),
5084 }
5085 }
5086 ValueRef::Blob(_) => match column.ty {
5087 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
5089 _ => mismatch(),
5090 },
5091 }
5092}
5093
5094pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
5095 use rusqlite::types::ValueRef;
5096 match value {
5097 ValueRef::Null => Value::Null,
5098 ValueRef::Integer(i) => match column.ty {
5099 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
5100 ssp2::segment::ColumnType::Float => {
5101 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
5102 }
5103 _ => Value::from(i),
5104 },
5105 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5106 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5107 ValueRef::Blob(b) => {
5108 let mut map = Map::new();
5109 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5110 Value::Object(map)
5111 }
5112 }
5113}
5114
5115fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
5119 Ok(match value {
5120 Value::Null => SqlValue::Null,
5121 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
5122 Value::Number(n) => {
5123 if let Some(i) = n.as_i64() {
5124 SqlValue::Integer(i)
5125 } else if let Some(f) = n.as_f64() {
5126 SqlValue::Real(f)
5127 } else {
5128 return Err(format!("query param number {n} is out of range"));
5129 }
5130 }
5131 Value::String(s) => SqlValue::Text(s.clone()),
5132 Value::Object(_) => {
5133 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
5134 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
5135 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
5136 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
5137 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
5138 })?)
5139 } else {
5140 return Err(
5141 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
5142 .to_owned(),
5143 );
5144 }
5145 }
5146 Value::Array(_) => return Err("query array params are not supported".to_owned()),
5147 })
5148}
5149
5150fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
5155 use rusqlite::types::ValueRef;
5156 match value {
5157 ValueRef::Null => Value::Null,
5158 ValueRef::Integer(i) => {
5159 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
5163 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
5164 Value::from(i)
5165 } else {
5166 let mut map = Map::new();
5167 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
5168 Value::Object(map)
5169 }
5170 }
5171 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5172 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5173 ValueRef::Blob(b) => {
5174 let mut map = Map::new();
5175 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5176 Value::Object(map)
5177 }
5178 }
5179}
5180
5181fn query_connection(
5183 conn: &Connection,
5184 sql: &str,
5185 params: &[QueryValue],
5186) -> Result<Vec<QueryRow>, QueryReadFailure> {
5187 crate::query_guard::assert_read_only_query(sql)?;
5188 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
5189 let bound: Vec<SqlValue> = params
5190 .iter()
5191 .map(json_param_to_sql)
5192 .collect::<Result<_, _>>()?;
5193 let mut stmt = conn.prepare(&lowered_sql)?;
5194 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
5195 let bound_refs: Vec<&dyn rusqlite::ToSql> =
5196 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
5197 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
5198 let mut out = Vec::new();
5199 while let Some(row) = sql_rows.next()? {
5200 let mut record = Map::new();
5201 for (i, name) in column_names.iter().enumerate() {
5202 let value = row.get_ref(i)?;
5203 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
5204 }
5205 out.push(record);
5206 }
5207 Ok(out)
5208}
5209
5210fn persisted_window_state(
5211 conn: &Connection,
5212 base: &WindowBase,
5213) -> Result<WindowState, QueryReadFailure> {
5214 let mut stmt = conn.prepare(
5215 "SELECT windows.unit, subscriptions.state_json
5216 FROM _syncular_windows AS windows
5217 JOIN _syncular_subscriptions AS subscriptions
5218 ON subscriptions.id = windows.sub_id
5219 WHERE windows.base = ?1
5220 ORDER BY windows.unit ASC",
5221 )?;
5222 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
5223 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5224 })?;
5225 let mut units = Vec::new();
5226 let mut pending = Vec::new();
5227 for row in rows {
5228 let (unit, raw) = row?;
5229 let state: Value = serde_json::from_str(&raw)
5230 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
5231 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
5232 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
5233 || state
5234 .get("bootstrapState")
5235 .is_some_and(|value| !value.is_null());
5236 if is_pending {
5237 pending.push(unit.clone());
5238 }
5239 units.push(unit);
5240 }
5241 Ok(WindowState { units, pending })
5242}
5243
5244fn snapshot_connection(
5245 conn: &Connection,
5246 sql: &str,
5247 params: &[Value],
5248 coverage: &[WindowCoverage],
5249) -> Result<QuerySnapshot, QueryReadFailure> {
5250 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
5251 let result = (|| {
5252 let revision = conn
5253 .query_row(
5254 "SELECT value FROM _syncular_meta WHERE key = ?1",
5255 rusqlite::params![LOCAL_REVISION_KEY],
5256 |row| row.get::<_, String>(0),
5257 )
5258 .ok()
5259 .and_then(|value| value.parse::<u64>().ok())
5260 .unwrap_or(0);
5261 let rows = query_connection(conn, sql, params)?;
5262 let mut pending = Vec::new();
5263 let mut missing = Vec::new();
5264 for requested in coverage {
5265 let base_key = window_base_key(&requested.base);
5266 let state = persisted_window_state(conn, &requested.base)?;
5267 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5268 let reference = WindowUnitRef {
5269 base_key: base_key.clone(),
5270 unit: unit.clone(),
5271 };
5272 if !state.units.iter().any(|held| held == &unit) {
5273 missing.push(reference);
5274 } else if state.pending.iter().any(|held| held == &unit) {
5275 pending.push(reference);
5276 }
5277 }
5278 }
5279 Ok(QuerySnapshot {
5280 revision: revision.to_string(),
5281 rows,
5282 coverage: CoverageSnapshot {
5283 complete: pending.is_empty() && missing.is_empty(),
5284 pending,
5285 missing,
5286 },
5287 })
5288 })();
5289 match result {
5290 Ok(snapshot) => {
5291 conn.execute_batch("RELEASE syncular_snapshot_read")?;
5292 Ok(snapshot)
5293 }
5294 Err(error) => {
5295 let _ = conn.execute_batch(
5296 "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
5297 );
5298 Err(error)
5299 }
5300 }
5301}
5302
5303pub struct FileQuerySnapshotReader {
5308 path: String,
5309 conn: Option<Connection>,
5310}
5311
5312impl FileQuerySnapshotReader {
5313 #[must_use]
5314 pub fn new(path: impl Into<String>) -> Self {
5315 Self {
5316 path: path.into(),
5317 conn: None,
5318 }
5319 }
5320
5321 fn connection(&mut self) -> Result<&Connection, String> {
5322 if self.conn.is_none() {
5323 let conn = Connection::open_with_flags(
5324 &self.path,
5325 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5326 )
5327 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5328 conn.busy_timeout(std::time::Duration::from_millis(250))
5329 .map_err(|error| error.to_string())?;
5330 self.conn = Some(conn);
5331 }
5332 self.conn
5333 .as_ref()
5334 .ok_or_else(|| "read sidecar connection missing".to_owned())
5335 }
5336
5337 pub fn query_snapshot(
5340 &mut self,
5341 sql: &str,
5342 params: &[Value],
5343 coverage: &[WindowCoverage],
5344 ) -> Result<QuerySnapshot, QueryReadFailure> {
5345 snapshot_connection(self.connection()?, sql, params, coverage)
5346 }
5347}
5348
5349impl SyncClient {
5350 pub fn new_with_identity(
5351 client_id: Option<String>,
5352 schema_json: &Value,
5353 limits: ClientLimits,
5354 ) -> Result<Self, String> {
5355 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5356 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5357 Self::with_connection(resolved, schema_json, limits, conn)
5358 }
5359
5360 pub fn new(
5361 client_id: String,
5362 schema_json: &Value,
5363 limits: ClientLimits,
5364 ) -> Result<Self, String> {
5365 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5366 Self::with_connection(client_id, schema_json, limits, conn)
5367 }
5368
5369 pub fn open_path(
5375 client_id: String,
5376 schema_json: &Value,
5377 limits: ClientLimits,
5378 path: &str,
5379 ) -> Result<Self, String> {
5380 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5381 Self::with_connection(client_id, schema_json, limits, conn)
5382 }
5383
5384 pub fn open_path_with_identity(
5385 client_id: Option<String>,
5386 schema_json: &Value,
5387 limits: ClientLimits,
5388 path: &str,
5389 ) -> Result<Self, String> {
5390 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5391 conn.busy_timeout(std::time::Duration::from_millis(250))
5397 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5398 conn.pragma_update(None, "journal_mode", "WAL")
5399 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5400 let persisted = conn
5401 .query_row(
5402 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5403 [],
5404 |row| row.get::<_, String>(0),
5405 )
5406 .ok();
5407 let resolved = persisted
5408 .clone()
5409 .or(client_id.clone())
5410 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5411 if let (Some(existing), Some(requested)) = (persisted, client_id) {
5412 if existing != requested {
5413 return Err(format!(
5414 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5415 ));
5416 }
5417 }
5418 Self::with_connection(resolved, schema_json, limits, conn)
5419 }
5420
5421 #[must_use]
5422 pub fn client_id(&self) -> &str {
5423 &self.client_id
5424 }
5425
5426 pub fn with_connection(
5433 client_id: String,
5434 schema_json: &Value,
5435 limits: ClientLimits,
5436 conn: Connection,
5437 ) -> Result<Self, String> {
5438 if limits.outcome_retention_max_entries == Some(0) {
5439 return Err(
5440 "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5441 );
5442 }
5443 let schema = parse_schema_json(schema_json)?;
5444 let previous_version = match limits.previous_version_context {
5447 Some(config) => {
5448 config.validate()?;
5449 Some(config)
5450 }
5451 None => None,
5452 };
5453 let mut client = SyncClient {
5454 #[cfg(feature = "bench-internals")]
5455 benchmark_phases: Recorder::default(),
5456 conn,
5457 schema,
5458 client_id,
5459 limits,
5460 subs: Vec::new(),
5461 outbox: Vec::new(),
5462 conflicts: Vec::new(),
5463 rejections: Vec::new(),
5464 schema_floor: None,
5465 lease_state: None,
5466 stopped: false,
5467 upgrading: false,
5468 sync_needed: false,
5469 realtime_policy: RealtimePolicy::Optional,
5470 realtime_state: RealtimeState::Disconnected,
5471 realtime_reason_code: None,
5472 realtime_retry_delay_ms: None,
5473 presence: HashMap::new(),
5474 now_ms: None,
5475 encryption: crate::values::EncryptionConfig::default(),
5476 security_preflight: false,
5477 previous_version,
5478 insert_sql: RefCell::new(HashMap::new()),
5479 overlay_dirty: Cell::new(false),
5480 #[cfg(test)]
5481 overlay_rebuild_count: Cell::new(0),
5482 #[cfg(test)]
5483 outcome_prune_count: Cell::new(0),
5484 progress: ProgressObserver::default(),
5485 change_queue: VecDeque::new(),
5486 sync_intent_queue: VecDeque::new(),
5487 retry_delay_ms: 250,
5488 last_round: None,
5489 last_change: None,
5490 query_failures: Vec::new(),
5491 };
5492 client
5496 .conn
5497 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5498 client.create_bookkeeping_tables()?;
5502 match client.get_meta(CLIENT_ID_KEY) {
5503 Some(existing) if existing != client.client_id => {
5504 return Err(format!(
5505 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5506 client.client_id
5507 ));
5508 }
5509 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5510 _ => {}
5511 }
5512 client.restore_persisted_state()?;
5513 let marker = client
5514 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5515 .and_then(|value| value.parse::<i32>().ok());
5516 match marker {
5517 None => {
5518 client.create_synced_tables()?;
5519 client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5520 set_local_schema_descriptor(&client.conn, &client.schema);
5522 }
5523 Some(version) if version == client.schema.version => {
5524 client.create_synced_tables()?;
5525 set_local_schema_descriptor(&client.conn, &client.schema);
5529 }
5530 Some(_) => client.run_schema_reset()?,
5531 }
5532 client.reconcile_previous_version_at_boot()?;
5538 client.clear_satisfied_persisted_schema_floor();
5539 client.prune_unknown_subscriptions()?;
5540 if marker == Some(client.schema.version) && !client.outbox.is_empty() {
5541 client.overlay_dirty.set(true);
5544 client.rebuild_overlay();
5545 }
5546 client.enqueue_startup_sync_if_needed()?;
5552 Ok(client)
5553 }
5554
5555 pub fn set_now_ms(&mut self, now_ms: i64) {
5558 self.now_ms = Some(now_ms);
5559 }
5560
5561 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
5565 self.encryption = encryption;
5566 }
5567
5568 #[must_use]
5569 pub fn security_lifecycle(&self) -> &'static str {
5570 if self.security_preflight {
5571 "preflight"
5572 } else {
5573 "active"
5574 }
5575 }
5576
5577 #[must_use]
5578 pub fn security_preflight(&self) -> bool {
5579 self.security_preflight
5580 }
5581
5582 pub fn begin_security_preflight(&mut self) {
5589 self.seal_security_on_teardown();
5590 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
5593 }
5594
5595 pub fn seal_security_on_teardown(&mut self) {
5605 self.security_preflight = true;
5606 self.encryption = crate::values::EncryptionConfig::default();
5607 self.sync_intent_queue.clear();
5608 }
5609
5610 pub fn activate_security(
5612 &mut self,
5613 encryption: crate::values::EncryptionConfig,
5614 ) -> Result<(), String> {
5615 if !self.security_preflight {
5616 return Err(
5617 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
5618 );
5619 }
5620 self.enqueue_startup_sync_if_needed()?;
5621 self.encryption = encryption;
5622 self.security_preflight = false;
5623 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
5624 Ok(())
5625 }
5626
5627 fn clock_now_ms(&self) -> i64 {
5628 self.now_ms.unwrap_or_else(|| {
5629 std::time::SystemTime::now()
5630 .duration_since(std::time::UNIX_EPOCH)
5631 .map(|d| d.as_millis() as i64)
5632 .unwrap_or(0)
5633 })
5634 }
5635
5636 fn create_bookkeeping_tables(&self) -> Result<(), String> {
5637 self.conn
5639 .execute_batch(
5640 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
5641 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5642 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
5643 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
5644 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5645 client_commit_id TEXT NOT NULL UNIQUE,
5646 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
5647 recorded_at_ms INTEGER NOT NULL,
5648 results_json TEXT NOT NULL,
5649 operations_json TEXT,
5650 resolution TEXT NOT NULL DEFAULT 'active'
5651 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
5652 resolved_at_ms INTEGER,
5653 replacement_client_commit_id TEXT);
5654 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
5655 ON _syncular_commit_outcomes(resolution, seq);
5656 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
5657 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
5658 CREATE TABLE IF NOT EXISTS _syncular_meta (
5659 key TEXT PRIMARY KEY, value TEXT NOT NULL);
5660 CREATE TABLE IF NOT EXISTS _syncular_windows (
5661 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
5662 PRIMARY KEY (base, unit));
5663 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
5664 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
5665 effective_scopes TEXT NOT NULL);
5666 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
5667 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
5668 PRIMARY KEY(commit_id, blob_id));
5669 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
5670 ON _syncular_blob_commit_refs(blob_id);",
5671 )
5672 .map_err(|e| e.to_string())?;
5673 let _ = self
5676 .conn
5677 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
5678 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
5679 self.set_meta(LOCAL_REVISION_KEY, "0");
5680 }
5681 if self.schema_has_blobs() {
5684 let exists = self
5685 .conn
5686 .query_row(
5687 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5688 [],
5689 |row| row.get::<_, bool>(0),
5690 )
5691 .map_err(|e| e.to_string())?;
5692 if exists {
5693 let mut statement = self
5694 .conn
5695 .prepare("PRAGMA table_info(_syncular_blobs)")
5696 .map_err(|e| e.to_string())?;
5697 let mut columns = statement
5698 .query_map([], |row| row.get::<_, String>(1))
5699 .map_err(|e| e.to_string())?
5700 .collect::<Result<Vec<_>, _>>()
5701 .map_err(|e| e.to_string())?;
5702 columns.sort();
5703 let mut expected = vec![
5704 "blob_id",
5705 "byte_length",
5706 "bytes",
5707 "created_at_ms",
5708 "media_type",
5709 ];
5710 expected.sort_unstable();
5711 if columns != expected {
5712 return Err(
5713 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5714 );
5715 }
5716 } else {
5717 self.conn
5718 .execute_batch(
5719 "CREATE TABLE _syncular_blobs(
5720 blob_id TEXT PRIMARY KEY,
5721 bytes BLOB NOT NULL,
5722 byte_length INTEGER NOT NULL,
5723 media_type TEXT,
5724 created_at_ms INTEGER NOT NULL);",
5725 )
5726 .map_err(|e| e.to_string())?;
5727 }
5728 self.conn
5729 .execute_batch(
5730 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5731 blob_id TEXT PRIMARY KEY,
5732 media_type TEXT,
5733 created_at_ms INTEGER NOT NULL);",
5734 )
5735 .map_err(|e| e.to_string())?;
5736 }
5737 Ok(())
5738 }
5739
5740 fn schema_has_blobs(&self) -> bool {
5742 self.schema
5743 .tables
5744 .iter()
5745 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5746 }
5747
5748 fn get_meta(&self, key: &str) -> Option<String> {
5751 meta_get(&self.conn, key)
5752 }
5753
5754 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5755 self.conn
5756 .query_row(
5757 "SELECT value FROM _syncular_meta WHERE key = ?1",
5758 rusqlite::params![key],
5759 |row| row.get::<_, String>(0),
5760 )
5761 .optional()
5762 .map_err(|_| {
5763 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5764 })
5765 }
5766
5767 fn set_meta(&self, key: &str, value: &str) {
5768 meta_set(&self.conn, key, value);
5769 }
5770
5771 fn delete_meta(&self, key: &str) {
5772 meta_delete(&self.conn, key);
5773 }
5774
5775 fn restore_persisted_state(&mut self) -> Result<(), String> {
5776 self.subs = {
5777 let mut stmt = self
5778 .conn
5779 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5780 .map_err(|error| error.to_string())?;
5781 let rows = stmt
5782 .query_map([], |row| {
5783 Ok((
5784 row.get::<_, String>(0)?,
5785 row.get::<_, String>(1)?,
5786 row.get::<_, String>(2)?,
5787 ))
5788 })
5789 .map_err(|error| error.to_string())?;
5790 let mut subscriptions = Vec::new();
5791 for row in rows {
5792 let (id, table, raw) = row.map_err(|error| error.to_string())?;
5793 let state: Value = serde_json::from_str(&raw)
5794 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5795 let requested = json_to_scope_map(
5796 state.get("requested").unwrap_or(&Value::Object(Map::new())),
5797 )?;
5798 let effective = state
5799 .get("effectiveScopes")
5800 .filter(|value| !value.is_null())
5801 .map(json_to_scope_map)
5802 .transpose()?;
5803 subscriptions.push(Subscription {
5804 id,
5805 table,
5806 requested,
5807 params: state
5808 .get("params")
5809 .and_then(Value::as_str)
5810 .map(str::to_owned),
5811 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
5812 bootstrap_state: state
5813 .get("bootstrapState")
5814 .and_then(Value::as_str)
5815 .map(str::to_owned),
5816 state: SubState::parse(
5817 state
5818 .get("status")
5819 .and_then(Value::as_str)
5820 .unwrap_or("active"),
5821 ),
5822 reason_code: state
5823 .get("reasonCode")
5824 .and_then(Value::as_str)
5825 .map(str::to_owned),
5826 effective,
5827 synced_once: state
5828 .get("syncedOnce")
5829 .and_then(Value::as_bool)
5830 .unwrap_or(false),
5831 });
5832 }
5833 subscriptions
5834 };
5835
5836 self.outbox = {
5837 let mut stmt = self
5838 .conn
5839 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
5840 .map_err(|error| error.to_string())?;
5841 let rows = stmt
5842 .query_map([], |row| {
5843 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5844 })
5845 .map_err(|error| error.to_string())?;
5846 let mut commits = Vec::new();
5847 for row in rows {
5848 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
5849 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
5850 format!("invalid persisted outbox {client_commit_id:?}: {error}")
5851 })?;
5852 let mut ops = Vec::with_capacity(entries.len());
5853 for entry in entries {
5854 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
5855 ops.push(OutboxOp {
5856 upsert: op == "upsert",
5857 table: entry
5858 .get("table")
5859 .and_then(Value::as_str)
5860 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
5861 .to_owned(),
5862 row_id: entry
5863 .get("rowId")
5864 .and_then(Value::as_str)
5865 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
5866 .to_owned(),
5867 base_version: entry.get("baseVersion").and_then(Value::as_i64),
5868 values: entry.get("values").and_then(Value::as_object).cloned(),
5871 });
5872 }
5873 commits.push(OutboxCommit {
5874 client_commit_id,
5875 ops,
5876 });
5877 }
5878 commits
5879 };
5880
5881 self.prune_commit_outcomes()?;
5882 let active = self.commit_outcomes(CommitOutcomeQuery {
5883 active_only: true,
5884 ..CommitOutcomeQuery::default()
5885 })?;
5886 self.conflicts = active
5887 .iter()
5888 .flat_map(|outcome| outcome.results.iter())
5889 .filter_map(|result| match result {
5890 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
5891 _ => None,
5892 })
5893 .collect();
5894 self.rejections = active
5895 .iter()
5896 .flat_map(|outcome| outcome.results.iter())
5897 .filter_map(|result| match result {
5898 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
5899 _ => None,
5900 })
5901 .collect();
5902
5903 self.lease_state = self
5904 .get_meta(LEASE_STATE_KEY)
5905 .map(|raw| serde_json::from_str(&raw))
5906 .transpose()
5907 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
5908 self.schema_floor = self
5909 .get_meta(SCHEMA_FLOOR_KEY)
5910 .map(|raw| serde_json::from_str(&raw))
5911 .transpose()
5912 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
5913 self.stopped = self.schema_floor.is_some();
5914 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
5917 self.security_preflight = true;
5918 }
5919 Ok(())
5920 }
5921
5922 fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
5925 let valid_tables: BTreeSet<String> = self
5926 .schema
5927 .tables
5928 .iter()
5929 .map(|table| table.name.clone())
5930 .collect();
5931 let stale_ids: Vec<String> = self
5932 .subs
5933 .iter()
5934 .filter(|sub| !valid_tables.contains(&sub.table))
5935 .map(|sub| sub.id.clone())
5936 .collect();
5937 for id in &stale_ids {
5938 self.conn
5939 .execute(
5940 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
5941 rusqlite::params![id],
5942 )
5943 .map_err(|error| error.to_string())?;
5944 self.conn
5945 .execute(
5946 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
5947 rusqlite::params![id],
5948 )
5949 .map_err(|error| error.to_string())?;
5950 self.conn
5951 .execute(
5952 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
5953 rusqlite::params![id],
5954 )
5955 .map_err(|error| error.to_string())?;
5956 }
5957 self.subs.retain(|sub| valid_tables.contains(&sub.table));
5958 Ok(())
5959 }
5960
5961 #[must_use]
5962 pub fn local_revision(&self) -> u64 {
5963 self.get_meta(LOCAL_REVISION_KEY)
5964 .and_then(|value| value.parse().ok())
5965 .unwrap_or(0)
5966 }
5967
5968 #[must_use]
5969 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
5970 SyncStatusSnapshot {
5971 current_schema_version: self.schema.version,
5972 outbox: self.outbox.len(),
5973 upgrading: self.upgrading,
5974 lease_state: self.lease_state.clone(),
5975 schema_floor: self.schema_floor.clone(),
5976 sync_needed: self.sync_needed,
5977 previous_version_context: self.previous_version_status(),
5978 }
5979 }
5980
5981 fn previous_version_status(&self) -> PreviousVersionStatus {
5984 let Some(replica_path) = self.previous_version_replica_path() else {
5985 return PreviousVersionStatus {
5986 present: false,
5987 created_at_ms: None,
5988 };
5989 };
5990 match crate::previous_version::read_previous_version_record(&replica_path) {
5991 Ok(Some(record)) => PreviousVersionStatus {
5992 present: true,
5993 created_at_ms: Some(record.created_at_ms),
5994 },
5995 _ => PreviousVersionStatus {
5996 present: std::path::Path::new(
5997 &crate::previous_version::previous_version_container_path(&replica_path),
5998 )
5999 .exists(),
6000 created_at_ms: None,
6001 },
6002 }
6003 }
6004
6005 pub fn previous_version_snapshot(
6011 &self,
6012 spec: &PreviousVersionReadSpec,
6013 ) -> Result<PreviousVersionSnapshot, String> {
6014 let now_ms = self.clock_now_ms();
6015 crate::previous_version::previous_version_snapshot(
6016 &self.conn,
6017 self.previous_version_replica_path().as_deref(),
6018 self.previous_version.as_ref(),
6019 PreviousVersionLifecycle {
6020 lease_inactive: self.previous_version_lease_inactive(now_ms),
6021 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
6022 coverage_complete: self.previous_version_coverage_complete(),
6023 },
6024 now_ms,
6025 self.schema.version,
6026 spec,
6027 )
6028 }
6029
6030 #[must_use]
6032 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
6033 crate::previous_version::stored_previous_version_audit(&self.conn)
6034 }
6035
6036 pub fn previous_version_discard(
6040 &self,
6041 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
6042 self.drop_previous_version()
6043 }
6044
6045 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
6049 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
6050 present: false,
6051 discarded: false,
6052 };
6053 let Some(replica_path) = self.previous_version_replica_path() else {
6054 return absent;
6055 };
6056 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
6057 .unwrap_or(absent)
6058 }
6059
6060 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
6062 let Some(lease) = &self.lease_state else {
6063 return false;
6064 };
6065 if lease.error_code.is_some() {
6066 return true;
6067 }
6068 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
6069 }
6070
6071 fn previous_version_coverage_complete(&self) -> bool {
6075 let active = self
6076 .subs
6077 .iter()
6078 .filter(|sub| sub.state == SubState::Active)
6079 .collect::<Vec<_>>();
6080 if active.is_empty() {
6081 return false;
6082 }
6083 active
6084 .iter()
6085 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
6086 }
6087
6088 fn previous_version_lifetime_check(&self) {
6092 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
6093 return;
6094 };
6095 let now_ms = self.clock_now_ms();
6096 if self.previous_version_lease_inactive(now_ms) {
6097 let _ = self.drop_previous_version();
6098 return;
6099 }
6100 let Some(replica_path) = self.previous_version_replica_path() else {
6101 return;
6102 };
6103 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
6104 else {
6105 return;
6106 };
6107 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
6108 let _ = self.drop_previous_version();
6109 return;
6110 }
6111 if now_ms - record.created_at_ms > config.max_age_ms {
6112 let _ = self.drop_previous_version();
6113 return;
6114 }
6115 if self.previous_version_coverage_complete() {
6116 let _ = self.drop_previous_version();
6117 }
6118 }
6119
6120 pub fn diagnostics_snapshot(
6121 &self,
6122 request: &ClientDiagnosticsRequest,
6123 ) -> Result<ClientDiagnosticsSnapshot, String> {
6124 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
6125 return Err(format!(
6126 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
6127 ));
6128 }
6129 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
6130 for sub in &self.subs {
6131 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
6132 let complete =
6133 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
6134 let state = match sub.state {
6135 SubState::Revoked => "revoked",
6136 SubState::Failed => "failed",
6137 SubState::Active if reset => "reset",
6138 SubState::Active if complete => "complete",
6139 SubState::Active => "bootstrapping",
6140 };
6141 subscriptions.insert(
6142 sub.id.clone(),
6143 DiagnosticSubscription {
6144 id: sub.id.clone(),
6145 table: sub.table.clone(),
6146 state: state.to_owned(),
6147 complete,
6148 cursor: Some(sub.cursor),
6149 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
6150 },
6151 );
6152 }
6153 for expected in &request.expected_subscriptions {
6154 if expected.id.is_empty() || expected.table.is_empty() {
6155 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
6156 }
6157 if subscriptions
6158 .get(&expected.id)
6159 .is_some_and(|registered| registered.table != expected.table)
6160 {
6161 subscriptions.insert(
6162 expected.id.clone(),
6163 DiagnosticSubscription {
6164 id: expected.id.clone(),
6165 table: expected.table.clone(),
6166 state: "failed".to_owned(),
6167 complete: false,
6168 cursor: None,
6169 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
6170 },
6171 );
6172 } else {
6173 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
6174 DiagnosticSubscription {
6175 id: expected.id.clone(),
6176 table: expected.table.clone(),
6177 state: "unregistered".to_owned(),
6178 complete: false,
6179 cursor: None,
6180 reason_code: None,
6181 }
6182 });
6183 }
6184 }
6185 let mut ordered_subscriptions = Vec::new();
6186 let mut included = BTreeSet::new();
6187 for expected in &request.expected_subscriptions {
6188 if included.insert(expected.id.clone()) {
6189 if let Some(subscription) = subscriptions.get(&expected.id) {
6190 ordered_subscriptions.push(subscription.clone());
6191 }
6192 }
6193 }
6194 for (id, subscription) in &subscriptions {
6195 if included.insert(id.clone()) {
6196 ordered_subscriptions.push(subscription.clone());
6197 }
6198 }
6199 let subscriptions_truncated =
6200 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
6201 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
6202 let captured_at_ms = self.clock_now_ms();
6203 let lease = if let Some(error_code) = self
6204 .lease_state
6205 .as_ref()
6206 .and_then(|state| state.error_code.clone())
6207 {
6208 ClientDiagnosticsLease {
6209 state: "stopped".to_owned(),
6210 expires_at_ms: self
6211 .lease_state
6212 .as_ref()
6213 .and_then(|state| state.expires_at_ms),
6214 error_code: Some(Self::diagnostic_code(&error_code)),
6215 }
6216 } else if let Some(expires_at_ms) = self
6217 .lease_state
6218 .as_ref()
6219 .and_then(|state| state.expires_at_ms)
6220 {
6221 ClientDiagnosticsLease {
6222 state: if expires_at_ms <= captured_at_ms {
6223 "expired".to_owned()
6224 } else {
6225 "active".to_owned()
6226 },
6227 expires_at_ms: Some(expires_at_ms),
6228 error_code: None,
6229 }
6230 } else {
6231 ClientDiagnosticsLease {
6232 state: "none".to_owned(),
6233 expires_at_ms: None,
6234 error_code: None,
6235 }
6236 };
6237 let connectivity = match self.last_round.as_ref() {
6238 Some(round) if round.status == "succeeded" => "online",
6239 Some(round)
6240 if round.status == "failed"
6241 && round
6242 .error_code
6243 .as_deref()
6244 .is_some_and(Self::retryable_transport_code) =>
6245 {
6246 "offline"
6247 }
6248 _ => "unknown",
6249 };
6250 Ok(ClientDiagnosticsSnapshot {
6251 version: CLIENT_DIAGNOSTICS_VERSION,
6252 captured_at_ms,
6253 host: ClientDiagnosticsHost {
6254 kind: "direct".to_owned(),
6255 role: "single".to_owned(),
6256 connectivity: connectivity.to_owned(),
6257 realtime: self.realtime_state,
6258 realtime_policy: self.realtime_policy,
6259 realtime_reason_code: self.realtime_reason_code.clone(),
6260 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
6261 },
6262 security_lifecycle: self.security_lifecycle().to_owned(),
6263 schema: ClientDiagnosticsSchema {
6264 current_version: self.schema.version,
6265 upgrading: self.upgrading,
6266 required_version: self
6267 .schema_floor
6268 .as_ref()
6269 .and_then(|floor| floor.required_schema_version),
6270 latest_version: self
6271 .schema_floor
6272 .as_ref()
6273 .and_then(|floor| floor.latest_schema_version),
6274 },
6275 replica: ClientDiagnosticsReplica {
6276 local_revision: self.local_revision().to_string(),
6277 sync_needed: self.sync_needed,
6278 pending_outbox: self.outbox.len(),
6279 },
6280 lease,
6281 subscriptions: ordered_subscriptions,
6282 subscriptions_truncated,
6283 last_round: self.last_round.clone(),
6284 last_change: self.last_change.clone(),
6285 storage: self.diagnostics_storage(),
6286 query_failures: self.query_failures.clone(),
6287 })
6288 }
6289
6290 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
6291 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
6292 let page_count: i64 = self
6293 .conn
6294 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
6295 let page_size: i64 = self
6296 .conn
6297 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
6298 let outbox_bytes: i64 = self
6300 .conn
6301 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
6302 .query_row([], |row| row.get(0))?;
6303 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
6304 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
6305 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
6306 let blob_bytes = if self.schema_has_blobs() {
6307 self.conn
6308 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
6309 .query_row([], |row| row.get(0))?
6310 } else {
6311 0
6312 };
6313 let pressure = self
6314 .limits
6315 .blob_cache_max_bytes
6316 .is_some_and(|limit| blob_bytes > limit);
6317 Ok(ClientDiagnosticsStorage {
6318 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
6319 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
6320 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
6321 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
6322 retained_outcome_entries: Some(outcome_entries.max(0)),
6323 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
6324 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
6325 })
6326 };
6327 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
6328 status: "unreadable".to_owned(),
6329 database_bytes_approx: None,
6330 pending_outbox_bytes_approx: None,
6331 retained_outcome_bytes_approx: None,
6332 retained_outcome_entries: None,
6333 blob_cache_bytes_approx: None,
6334 pressure_reason_code: None,
6335 })
6336 }
6337
6338 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
6339 self.change_queue.drain(..).collect()
6340 }
6341
6342 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
6343 self.sync_intent_queue.drain(..).collect()
6344 }
6345
6346 fn schedule_background_retry(&mut self) -> u64 {
6347 let delay_ms = self.retry_delay_ms;
6348 self.sync_intent_queue
6349 .push_back(SyncIntent::Background { delay_ms });
6350 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
6351 delay_ms
6352 }
6353
6354 fn reset_background_retry(&mut self) {
6355 self.retry_delay_ms = 250;
6356 }
6357
6358 fn retryable_transport_code(code: &str) -> bool {
6359 code == "transport.failed"
6360 || code == "transport.unavailable"
6361 || code == "sync.transport_failed"
6362 }
6363
6364 fn retryable_failure_code(code: &str) -> bool {
6369 Self::retryable_transport_code(code)
6370 || matches!(
6371 code,
6372 "sync.auth_required"
6373 | "sync.auth_lease_required"
6374 | "sync.auth_lease_revoked"
6375 | "sync.internal_error"
6376 | "sync.idempotency_cache_miss"
6377 | "sync.schema_not_ready"
6378 | "sync.segment_expired"
6379 | "sync.rate_limited"
6380 | "sync.websocket_connection_limit"
6381 )
6382 }
6383
6384 fn diagnostic_code(code: &str) -> String {
6385 let valid = !code.is_empty()
6386 && code.len() <= 96
6387 && code.contains('.')
6388 && code
6389 .bytes()
6390 .next()
6391 .is_some_and(|byte| byte.is_ascii_lowercase())
6392 && code.bytes().all(|byte| {
6393 byte.is_ascii_lowercase()
6394 || byte.is_ascii_digit()
6395 || matches!(byte, b'.' | b'_' | b'-')
6396 });
6397 if valid {
6398 code.to_owned()
6399 } else {
6400 "client.unknown_failure".to_owned()
6401 }
6402 }
6403
6404 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
6405 if self.sync_needed != value {
6406 if self.begin_observation("syncular_status").is_ok() {
6407 self.sync_needed = value;
6408 let batch = ChangeAccumulator {
6409 status: true,
6410 ..ChangeAccumulator::default()
6411 };
6412 if self.finish_observation("syncular_status", batch).is_err() {
6413 self.rollback_observation("syncular_status");
6414 }
6415 } else {
6416 self.sync_needed = value;
6417 }
6418 }
6419 if value && interactive {
6420 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6421 }
6422 }
6423
6424 fn begin_observation(&self, name: &str) -> Result<(), String> {
6425 self.conn
6426 .execute_batch(&format!("SAVEPOINT {name}"))
6427 .map_err(|error| error.to_string())
6428 }
6429
6430 fn rollback_observation(&self, name: &str) {
6431 let _ = self
6432 .conn
6433 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
6434 }
6435
6436 fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
6437 #[cfg(feature = "bench-internals")]
6438 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
6439 if !batch.touched() {
6440 self.conn
6441 .execute_batch(&format!("RELEASE {name}"))
6442 .map_err(|error| error.to_string())?;
6443 return Ok(());
6444 }
6445 let revision = self
6446 .local_revision()
6447 .checked_add(1)
6448 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
6449 self.conn
6450 .execute(
6451 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
6452 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
6453 )
6454 .map_err(|error| error.to_string())?;
6455 let status = batch.status.then(|| self.status_snapshot());
6456 let event = ClientChangeBatch {
6457 revision: revision.to_string(),
6458 tables: batch
6459 .tables
6460 .into_iter()
6461 .map(|(table, scope_keys)| TableChange {
6462 table,
6463 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
6464 })
6465 .collect(),
6466 windows: batch
6467 .windows
6468 .into_iter()
6469 .map(|((base_key, table), units)| WindowChange {
6470 base_key,
6471 table,
6472 units: units.into_iter().collect(),
6473 })
6474 .collect(),
6475 status,
6476 conflicts_changed: batch.conflicts,
6477 rejections_changed: batch.rejections,
6478 outcomes_changed: batch.outcomes,
6479 };
6480 self.conn
6481 .execute_batch(&format!("RELEASE {name}"))
6482 .map_err(|error| error.to_string())?;
6483 let mut diagnostic_tables = event
6484 .tables
6485 .iter()
6486 .map(|entry| entry.table.clone())
6487 .collect::<BTreeSet<_>>()
6488 .into_iter()
6489 .collect::<Vec<_>>();
6490 let mut diagnostic_windows = event
6491 .windows
6492 .iter()
6493 .map(|entry| entry.table.clone())
6494 .collect::<BTreeSet<_>>()
6495 .into_iter()
6496 .collect::<Vec<_>>();
6497 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
6498 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
6499 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
6500 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
6501 self.last_change = Some(DiagnosticLastChange {
6502 revision: event.revision.clone(),
6503 recorded_at_ms: self.clock_now_ms(),
6504 tables: diagnostic_tables,
6505 windows: diagnostic_windows,
6506 domains_truncated,
6507 status_changed: event.status.is_some(),
6508 conflicts_changed: event.conflicts_changed,
6509 rejections_changed: event.rejections_changed,
6510 outcomes_changed: event.outcomes_changed,
6511 });
6512 self.change_queue.push_back(event);
6513 Ok(())
6514 }
6515
6516 fn record_scope_map(
6517 &self,
6518 batch: &mut ChangeAccumulator,
6519 table_name: &str,
6520 scopes: &[(String, Vec<String>)],
6521 ) {
6522 let Some(table) = self.schema.table(table_name) else {
6523 return;
6524 };
6525 for (variable, values) in scopes {
6526 let Some(scope) = table
6527 .scope_variables
6528 .iter()
6529 .find(|scope| &scope.variable == variable)
6530 else {
6531 continue;
6532 };
6533 for value in values {
6534 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6535 }
6536 }
6537 }
6538
6539 fn record_row_scopes(
6541 &self,
6542 batch: &mut ChangeAccumulator,
6543 table_name: &str,
6544 row_id: &str,
6545 base: bool,
6546 ) -> bool {
6547 let Some(table) = self.schema.table(table_name) else {
6548 return false;
6549 };
6550 if table.scope_variables.is_empty() {
6551 return false;
6552 }
6553 let columns = table
6554 .scope_variables
6555 .iter()
6556 .map(|scope| quote_ident(&scope.column))
6557 .collect::<Vec<_>>()
6558 .join(", ");
6559 let full_table = if base {
6560 base_table(table_name)
6561 } else {
6562 visible_table(table_name)
6563 };
6564 let sql = format!(
6565 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
6566 row_id_predicate(table)
6567 );
6568 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
6569 return false;
6570 };
6571 let values = stmt.query_row(rusqlite::params![row_id], |row| {
6572 let mut values = Vec::with_capacity(table.scope_variables.len());
6573 for index in 0..table.scope_variables.len() {
6574 values.push(row.get::<_, Option<String>>(index)?);
6575 }
6576 Ok(values)
6577 });
6578 let Ok(values) = values else {
6579 return false;
6580 };
6581 let mut recorded = false;
6582 for (scope, value) in table.scope_variables.iter().zip(values) {
6583 if let Some(value) = value {
6584 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6585 recorded = true;
6586 }
6587 }
6588 recorded
6589 }
6590
6591 fn record_commit_changes(
6592 &self,
6593 batch: &mut ChangeAccumulator,
6594 tables: &[String],
6595 changes: &[ssp2::model::Change],
6596 ) {
6597 #[cfg(feature = "bench-internals")]
6598 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
6599 for change in changes {
6600 let Some(table_name) = tables.get(change.table_index as usize) else {
6601 continue;
6602 };
6603 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
6604 if let Some(table) = self.schema.table(table_name) {
6605 for (variable, value) in &change.scopes {
6606 if let Some(scope) = table
6607 .scope_variables
6608 .iter()
6609 .find(|scope| &scope.variable == variable)
6610 {
6611 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6612 precise = true;
6613 }
6614 }
6615 }
6616 if !precise {
6617 batch.table(table_name);
6618 }
6619 }
6620 }
6621
6622 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
6623 if effective.is_empty() {
6624 return false;
6625 }
6626 let Some(table) = self.schema.table(table_name) else {
6627 return false;
6628 };
6629 let mut clauses = Vec::new();
6630 let mut params = Vec::new();
6631 for (variable, values) in effective {
6632 let Some(column) = table.scope_column(variable) else {
6633 return false;
6634 };
6635 if values.is_empty() {
6636 return false;
6637 }
6638 let placeholders = values
6639 .iter()
6640 .map(|value| {
6641 params.push(SqlValue::Text(value.clone()));
6642 "?"
6643 })
6644 .collect::<Vec<_>>()
6645 .join(", ");
6646 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
6647 }
6648 let sql = format!(
6649 "SELECT 1 FROM {} WHERE {} LIMIT 1",
6650 base_table(table_name),
6651 clauses.join(" AND ")
6652 );
6653 self.conn
6654 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
6655 .is_ok()
6656 }
6657
6658 pub fn upgrading(&self) -> bool {
6660 self.upgrading
6661 }
6662
6663 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
6669 let new_schema = parse_schema_json(schema_json)?;
6670 let marker: Option<i32> = self
6671 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6672 .and_then(|v| v.parse().ok());
6673 self.schema = new_schema;
6674 self.create_bookkeeping_tables()?;
6675 if marker != Some(self.schema.version) {
6676 self.run_schema_reset()?;
6677 }
6678 self.prune_unknown_subscriptions()?;
6679 self.enqueue_startup_sync_if_needed()?;
6683 Ok(())
6684 }
6685
6686 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
6687 let startup_work = !self.stopped
6688 && (!self.outbox.is_empty()
6689 || !self.load_pending_evictions()?.is_empty()
6690 || self.subs.iter().any(|sub| sub.state == SubState::Active));
6691 if startup_work {
6692 self.sync_needed = true;
6693 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6694 }
6695 Ok(())
6696 }
6697
6698 fn clear_satisfied_persisted_schema_floor(&mut self) {
6705 let satisfied = self
6706 .schema_floor
6707 .as_ref()
6708 .and_then(|floor| floor.required_schema_version)
6709 .is_some_and(|required| self.schema.version >= required);
6710 if !satisfied {
6711 return;
6712 }
6713 self.schema_floor = None;
6714 self.stopped = false;
6715 self.delete_meta(SCHEMA_FLOOR_KEY);
6716 }
6717
6718 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
6723 let Some(replica_path) = self.previous_version_replica_path() else {
6724 return Ok(());
6725 };
6726 let max_age_ms = self
6727 .previous_version
6728 .filter(|config| config.enabled)
6729 .map(|config| config.max_age_ms);
6730 reconcile_previous_version_at_boot(
6731 &self.conn,
6732 &replica_path,
6733 self.schema.version,
6734 max_age_ms,
6735 self.clock_now_ms(),
6736 )
6737 }
6738
6739 fn previous_version_replica_path(&self) -> Option<String> {
6743 let path = self.conn.path()?;
6744 if path.is_empty() || path.starts_with("file:") {
6745 return None;
6746 }
6747 Some(path.to_owned())
6748 }
6749
6750 fn run_schema_reset(&mut self) -> Result<(), String> {
6756 self.begin_observation("syncular_schema_reset")?;
6757 let mut batch = ChangeAccumulator::default();
6758 let result = self.run_schema_reset_observed(&mut batch, true, true);
6759 if let Err(error) = result {
6760 self.rollback_observation("syncular_schema_reset");
6761 return Err(error);
6762 }
6763 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
6764 self.rollback_observation("syncular_schema_reset");
6765 return Err(error);
6766 }
6767 Ok(())
6768 }
6769
6770 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
6771 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
6772 self.begin_observation("syncular_log_epoch_reset")?;
6773 let mut batch = ChangeAccumulator::default();
6774 let result = self.run_schema_reset_observed(&mut batch, false, false);
6775 if let Err(error) = result {
6776 self.rollback_observation("syncular_log_epoch_reset");
6777 return Err(error);
6778 }
6779 self.set_meta(LOG_EPOCH_KEY, log_epoch);
6780 self.sync_needed = true;
6781 batch.status = true;
6782 if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
6783 self.rollback_observation("syncular_log_epoch_reset");
6784 return Err(error);
6785 }
6786 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6787 Ok(resets)
6788 }
6789
6790 fn run_schema_reset_observed(
6791 &mut self,
6792 batch: &mut ChangeAccumulator,
6793 drop_incompatible: bool,
6794 capture: bool,
6795 ) -> Result<(), String> {
6796 self.upgrading = true;
6797 batch.status = true;
6798 if self.previous_version.is_some_and(|config| config.enabled) {
6804 let previous_version = self
6805 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6806 .and_then(|value| value.parse::<i32>().ok())
6807 .unwrap_or(self.schema.version);
6808 let pending = self
6809 .outbox
6810 .iter()
6811 .map(|commit| PendingCommitAudit {
6812 commit_id: &commit.client_commit_id,
6813 operations: commit_audit_operations(commit),
6814 })
6815 .collect::<Vec<_>>();
6816 crate::previous_version::write_previous_version_audit(
6817 &self.conn,
6818 &crate::previous_version::build_previous_version_audit(
6819 &self.schema,
6820 &pending,
6821 previous_version,
6822 self.schema.version,
6823 self.clock_now_ms(),
6824 ),
6825 );
6826 }
6827 if let Some(replica_path) = self.previous_version_replica_path() {
6832 sweep_previous_version_container(&self.conn, &replica_path)?;
6833 if capture {
6834 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
6835 let previous_version = self
6836 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6837 .and_then(|value| value.parse::<i32>().ok())
6838 .unwrap_or(self.schema.version);
6839 capture_previous_version_from_replica(
6840 &self.conn,
6841 &replica_path,
6842 previous_version,
6843 self.schema.version,
6844 &config,
6845 self.clock_now_ms(),
6846 )?;
6847 }
6848 }
6849 }
6850 for table in &self.schema.tables {
6851 batch.table(&table.name);
6852 }
6853 for (base_key, unit, table) in self.load_registered_window_units() {
6854 batch.window(&base_key, &table, &unit);
6855 }
6856 self.insert_sql.borrow_mut().clear();
6858 self.overlay_dirty.set(true);
6859 let virtual_tables: Vec<String> = {
6867 let mut stmt = self
6868 .conn
6869 .prepare(
6870 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
6871 )
6872 .map_err(|e| e.to_string())?;
6873 let rows = stmt
6874 .query_map([], |row| row.get::<_, String>(0))
6875 .map_err(|e| e.to_string())?;
6876 rows.filter_map(Result::ok)
6877 .filter(|name| is_synced_table_name(name))
6878 .collect()
6879 };
6880 for name in virtual_tables {
6881 self.conn
6882 .execute_batch(&format!(
6883 "DROP TABLE IF EXISTS {}",
6884 quote_ident(&format!("_syncular_fts_{name}"))
6885 ))
6886 .map_err(|e| e.to_string())?;
6887 self.conn
6888 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6889 .map_err(|e| e.to_string())?;
6890 }
6891 let existing: Vec<String> = {
6892 let mut stmt = self
6893 .conn
6894 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
6895 .map_err(|e| e.to_string())?;
6896 let rows = stmt
6897 .query_map([], |row| row.get::<_, String>(0))
6898 .map_err(|e| e.to_string())?;
6899 rows.filter_map(Result::ok)
6900 .filter(|name| is_synced_table_name(name))
6901 .collect()
6902 };
6903 for name in &existing {
6904 if let Some(table) = name.strip_prefix("_syncular_base_") {
6905 batch.table(table);
6906 } else if !name.starts_with("_syncular_") {
6907 batch.table(name);
6908 }
6909 }
6910 for name in existing {
6911 self.conn
6912 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6913 .map_err(|e| e.to_string())?;
6914 }
6915 self.create_synced_tables()?;
6917 for sub in &mut self.subs {
6919 sub.cursor = -1;
6920 sub.bootstrap_state = None;
6921 sub.effective = None;
6922 sub.state = SubState::Active;
6923 sub.reason_code = None;
6924 sub.synced_once = false;
6925 }
6926 let subs = self.subs.clone();
6927 for sub in &subs {
6928 self.persist_sub(sub)?;
6929 }
6930 self.stopped = false;
6932 self.schema_floor = None;
6933 self.delete_meta(SCHEMA_FLOOR_KEY);
6934 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
6936 set_local_schema_descriptor(&self.conn, &self.schema);
6939 if drop_incompatible && self.drop_incompatible_outbox()? {
6943 batch.rejections = true;
6944 batch.status = true;
6945 batch.outcomes = true;
6946 }
6947 self.rebuild_overlay();
6949 Ok(())
6950 }
6951
6952 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
6956 let incompatible = self
6957 .outbox
6958 .iter()
6959 .filter(|commit| {
6960 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
6961 })
6962 .cloned()
6963 .collect::<Vec<_>>();
6964 if incompatible.is_empty() {
6965 return Ok(false);
6966 }
6967 let mut rejections = Vec::new();
6968 for commit in &incompatible {
6969 let results = commit
6970 .ops
6971 .iter()
6972 .enumerate()
6973 .map(|(op_index, operation)| {
6974 let rejection = RejectionRecord {
6975 client_commit_id: commit.client_commit_id.clone(),
6976 op_index: op_index as i32,
6977 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
6978 message: "the persisted commit cannot encode under the current schema"
6979 .to_owned(),
6980 retryable: false,
6981 details: None,
6982 operation: Some(CommitOperation::from(operation)),
6983 };
6984 rejections.push(rejection.clone());
6985 CommitOperationOutcome::Error { rejection }
6986 })
6987 .collect::<Vec<_>>();
6988 self.persist_commit_outcome(
6989 &commit.client_commit_id,
6990 CommitOutcomeStatus::Rejected,
6991 &results,
6992 Some(&commit.ops),
6993 )?;
6994 self.delete_outbox_persisted(&commit.client_commit_id)?;
6995 }
6996 self.prune_commit_outcomes()?;
6997 let incompatible_ids = incompatible
6998 .iter()
6999 .map(|commit| commit.client_commit_id.as_str())
7000 .collect::<BTreeSet<_>>();
7001 self.outbox
7002 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
7003 self.rejections.extend(rejections);
7004 Ok(true)
7005 }
7006
7007 fn create_synced_tables(&self) -> Result<(), String> {
7010 for table in &self.schema.tables {
7011 for (full, index_prefix) in [
7015 (base_table(&table.name), "_syncular_base_"),
7016 (visible_table(&table.name), ""),
7017 ] {
7018 let mut cols: Vec<String> =
7019 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
7020 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
7021 let sql = format!(
7022 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
7023 cols.join(", "),
7024 quote_ident(&table.primary_key)
7025 );
7026 self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
7027 for index in &table.indexes {
7031 let unique = if index.unique { "UNIQUE " } else { "" };
7032 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
7033 let cols_sql = index
7034 .columns
7035 .iter()
7036 .map(|c| quote_ident(c))
7037 .collect::<Vec<_>>()
7038 .join(", ");
7039 let index_sql = format!(
7040 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
7041 );
7042 self.conn
7043 .execute(&index_sql, [])
7044 .map_err(|e| e.to_string())?;
7045 }
7046 }
7047 }
7048 for table in &self.schema.tables {
7051 for index in &table.fts_indexes {
7052 self.create_fts_projection(table, index)?;
7053 }
7054 }
7055 Ok(())
7056 }
7057
7058 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
7059 let count: i64 = self
7060 .conn
7061 .query_row(
7062 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
7063 rusqlite::params![index.name],
7064 |row| row.get(0),
7065 )
7066 .map_err(|e| e.to_string())?;
7067 Ok(count > 0)
7068 }
7069
7070 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
7071 for suffix in ["ai", "ad", "au"] {
7072 self.conn
7073 .execute(
7074 &format!(
7075 "DROP TRIGGER IF EXISTS {}",
7076 quote_ident(&format!("{}_{suffix}", index.name))
7077 ),
7078 [],
7079 )
7080 .map_err(|e| e.to_string())?;
7081 }
7082 Ok(())
7083 }
7084
7085 fn create_fts_triggers(
7086 &self,
7087 table: &TableSchema,
7088 index: &FtsIndexSchema,
7089 ) -> Result<(), String> {
7090 let fts = quote_ident(&index.name);
7091 let source = visible_table(&table.name);
7092 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7093 let pk = quote_ident(&table.primary_key);
7094 let projection_columns = std::iter::once(source_id.clone())
7095 .chain(index.columns.iter().map(|column| quote_ident(column)))
7096 .collect::<Vec<_>>()
7097 .join(", ");
7098 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
7099 .chain(
7100 index
7101 .columns
7102 .iter()
7103 .map(|column| format!("new.{}", quote_ident(column))),
7104 )
7105 .collect::<Vec<_>>()
7106 .join(", ");
7107 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7108 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)");
7109 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)");
7110 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})");
7111 let bi = quote_ident(&format!("{}_bi", index.name));
7112 let ai = quote_ident(&format!("{}_ai", index.name));
7113 let ad = quote_ident(&format!("{}_ad", index.name));
7114 let au = quote_ident(&format!("{}_au", index.name));
7115 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
7116 let sql = format!(
7117 "DROP TRIGGER IF EXISTS {bi};
7118 DROP TRIGGER IF EXISTS {ai};
7119 DROP TRIGGER IF EXISTS {ad};
7120 DROP TRIGGER IF EXISTS {au};
7121 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
7122 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
7123 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
7124 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
7125 );
7126 self.conn.execute_batch(&sql).map_err(|e| e.to_string())
7127 }
7128
7129 fn rebuild_fts_projection(
7130 &self,
7131 table: &TableSchema,
7132 index: &FtsIndexSchema,
7133 ) -> Result<(), String> {
7134 let fts = quote_ident(&index.name);
7135 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7136 let pk = quote_ident(&table.primary_key);
7137 let indexed_columns = index
7138 .columns
7139 .iter()
7140 .map(|column| quote_ident(column))
7141 .collect::<Vec<_>>();
7142 let projection_columns = std::iter::once(source_id.clone())
7143 .chain(indexed_columns.iter().cloned())
7144 .collect::<Vec<_>>()
7145 .join(", ");
7146 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7147 let sql = format!(
7148 "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};",
7149 columns = indexed_columns.join(", "),
7150 source = visible_table(&table.name),
7151 );
7152 self.conn.execute_batch(&sql).map_err(|e| e.to_string())
7153 }
7154
7155 fn create_fts_projection(
7156 &self,
7157 table: &TableSchema,
7158 index: &FtsIndexSchema,
7159 ) -> Result<(), String> {
7160 self.begin_observation("syncular_fts_schema")?;
7161 let result = (|| {
7162 let existed = self.fts_projection_exists(index)?;
7163 let tokenizer = index.tokenize.replace('\'', "''");
7164 let columns = index
7165 .columns
7166 .iter()
7167 .map(|column| quote_ident(column))
7168 .collect::<Vec<_>>()
7169 .join(", ");
7170 let sql = format!(
7171 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
7172 fts = quote_ident(&index.name),
7173 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
7174 );
7175 self.conn.execute(&sql, []).map_err(|error| {
7176 format!(
7177 "cannot create local FTS5 projection {:?}: {error}",
7178 index.name
7179 )
7180 })?;
7181 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7182 let mapped: bool = self
7183 .conn
7184 .query_row(
7185 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
7186 [format!("_syncular_fts_{}", index.name)],
7187 |row| row.get(0),
7188 )
7189 .map_err(|e| e.to_string())?;
7190 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())?;
7191 if !mapped && existed {
7192 self.conn
7193 .execute_batch(&format!(
7194 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
7195 quote_ident(FTS_SOURCE_ID_COLUMN),
7196 quote_ident(&index.name)
7197 ))
7198 .map_err(|e| e.to_string())?;
7199 }
7200 self.create_fts_triggers(table, index)?;
7201 if !existed {
7202 self.rebuild_fts_projection(table, index)?;
7203 }
7204 Ok(())
7205 })();
7206 if let Err(error) = result {
7207 self.rollback_observation("syncular_fts_schema");
7208 return Err(error);
7209 }
7210 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
7211 self.rollback_observation("syncular_fts_schema");
7212 return Err(error.to_string());
7213 }
7214 Ok(())
7215 }
7216
7217 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
7220 let state = serde_json::json!({
7221 "requested": scope_map_to_json(&sub.requested),
7222 "params": sub.params,
7223 "cursor": sub.cursor,
7224 "bootstrapState": sub.bootstrap_state,
7225 "status": sub.state.name(),
7226 "reasonCode": sub.reason_code,
7227 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7228 "syncedOnce": sub.synced_once,
7229 });
7230 self.conn.execute(
7231 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
7232 rusqlite::params![sub.id, sub.table, state.to_string()],
7233 ).map(|_| ()).map_err(|error| error.to_string())
7234 }
7235
7236 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
7237 let ops: Vec<Value> = commit
7238 .ops
7239 .iter()
7240 .map(|op| {
7241 serde_json::json!({
7242 "op": if op.upsert { "upsert" } else { "delete" },
7243 "table": op.table,
7244 "rowId": op.row_id,
7245 "baseVersion": op.base_version,
7246 "values": op.values.clone().map(Value::Object),
7247 })
7248 })
7249 .collect();
7250 self.conn
7251 .execute(
7252 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
7253 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
7254 )
7255 .map_err(|error| error.to_string())?;
7256 for operation in &commit.ops {
7257 let Some(values) = &operation.values else {
7258 continue;
7259 };
7260 let Some(table) = self.schema.table(&operation.table) else {
7261 continue;
7262 };
7263 for column in table
7264 .columns
7265 .iter()
7266 .filter(|column| column.ty == ColumnType::BlobRef)
7267 {
7268 let Some(Value::String(raw)) = values.get(&column.name) else {
7269 continue;
7270 };
7271 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
7272 continue;
7273 };
7274 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
7275 continue;
7276 };
7277 self.conn
7278 .execute(
7279 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
7280 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
7281 rusqlite::params![commit.client_commit_id, blob_id],
7282 )
7283 .map_err(|error| error.to_string())?;
7284 }
7285 }
7286 Ok(())
7287 }
7288
7289 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
7290 self.conn
7291 .execute(
7292 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
7293 rusqlite::params![client_commit_id],
7294 )
7295 .map_err(|error| error.to_string())?;
7296 self.conn
7297 .execute(
7298 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
7299 rusqlite::params![client_commit_id],
7300 )
7301 .map(|_| ())
7302 .map_err(|error| error.to_string())
7303 }
7304
7305 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
7306 match status {
7307 CommitOutcomeStatus::Applied => "applied",
7308 CommitOutcomeStatus::Cached => "cached",
7309 CommitOutcomeStatus::Conflict => "conflict",
7310 CommitOutcomeStatus::Rejected => "rejected",
7311 }
7312 }
7313
7314 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
7315 match resolution {
7316 CommitOutcomeResolution::Active => "active",
7317 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
7318 CommitOutcomeResolution::Superseded => "superseded",
7319 CommitOutcomeResolution::Dismissed => "dismissed",
7320 }
7321 }
7322
7323 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
7324 match value {
7325 "applied" => Ok(CommitOutcomeStatus::Applied),
7326 "cached" => Ok(CommitOutcomeStatus::Cached),
7327 "conflict" => Ok(CommitOutcomeStatus::Conflict),
7328 "rejected" => Ok(CommitOutcomeStatus::Rejected),
7329 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
7330 }
7331 }
7332
7333 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
7334 match value {
7335 "active" => Ok(CommitOutcomeResolution::Active),
7336 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
7337 "superseded" => Ok(CommitOutcomeResolution::Superseded),
7338 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
7339 _ => Err(format!(
7340 "invalid persisted commit outcome resolution {value:?}"
7341 )),
7342 }
7343 }
7344
7345 fn persist_commit_outcome(
7346 &self,
7347 client_commit_id: &str,
7348 status: CommitOutcomeStatus,
7349 results: &[CommitOperationOutcome],
7350 operations: Option<&[OutboxOp]>,
7351 ) -> Result<(), String> {
7352 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
7353 let operations_json = operations
7354 .map(|items| {
7355 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
7356 })
7357 .transpose()
7358 .map_err(|error| error.to_string())?;
7359 self.conn
7360 .execute(
7361 "INSERT INTO _syncular_commit_outcomes (
7362 client_commit_id, status, recorded_at_ms, results_json,
7363 operations_json, resolution
7364 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
7365 rusqlite::params![
7366 client_commit_id,
7367 Self::outcome_status_name(status),
7368 self.clock_now_ms(),
7369 results_json,
7370 operations_json
7371 ],
7372 )
7373 .map(|_| ())
7374 .map_err(|error| error.to_string())
7375 }
7376
7377 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
7378 let StoredCommitOutcomeRow {
7379 sequence,
7380 client_commit_id,
7381 status,
7382 recorded_at_ms,
7383 results_json,
7384 operations_json,
7385 resolution,
7386 resolved_at_ms,
7387 replacement_client_commit_id,
7388 } = row;
7389 Ok(CommitOutcome {
7390 sequence,
7391 client_commit_id,
7392 status: Self::parse_outcome_status(&status)?,
7393 recorded_at_ms,
7394 results: serde_json::from_str(&results_json)
7395 .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
7396 operations: operations_json
7397 .map(|value| {
7398 serde_json::from_str(&value).map_err(|error| {
7399 format!("invalid persisted commit outcome operations: {error}")
7400 })
7401 })
7402 .transpose()?,
7403 resolution: Self::parse_outcome_resolution(&resolution)?,
7404 resolved_at_ms,
7405 replacement_client_commit_id,
7406 })
7407 }
7408
7409 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
7410 let row = self
7411 .conn
7412 .query_row(
7413 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7414 resolution, resolved_at_ms, replacement_client_commit_id
7415 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
7416 rusqlite::params![client_commit_id],
7417 |row| {
7418 Ok(StoredCommitOutcomeRow {
7419 sequence: row.get(0)?,
7420 client_commit_id: row.get(1)?,
7421 status: row.get(2)?,
7422 recorded_at_ms: row.get(3)?,
7423 results_json: row.get(4)?,
7424 operations_json: row.get(5)?,
7425 resolution: row.get(6)?,
7426 resolved_at_ms: row.get(7)?,
7427 replacement_client_commit_id: row.get(8)?,
7428 })
7429 },
7430 )
7431 .optional()
7432 .map_err(|error| error.to_string())?;
7433 row.map(Self::outcome_from_row).transpose()
7434 }
7435
7436 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
7437 if query.limit == Some(0) {
7438 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
7439 }
7440 let mut sql = String::from(
7441 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7442 resolution, resolved_at_ms, replacement_client_commit_id
7443 FROM _syncular_commit_outcomes",
7444 );
7445 if query.active_only {
7446 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
7447 }
7448 sql.push_str(" ORDER BY seq DESC");
7449 if let Some(limit) = query.limit {
7450 sql.push_str(&format!(" LIMIT {limit}"));
7451 }
7452 let mut stmt = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
7453 let rows = stmt
7454 .query_map([], |row| {
7455 Ok(StoredCommitOutcomeRow {
7456 sequence: row.get(0)?,
7457 client_commit_id: row.get(1)?,
7458 status: row.get(2)?,
7459 recorded_at_ms: row.get(3)?,
7460 results_json: row.get(4)?,
7461 operations_json: row.get(5)?,
7462 resolution: row.get(6)?,
7463 resolved_at_ms: row.get(7)?,
7464 replacement_client_commit_id: row.get(8)?,
7465 })
7466 })
7467 .map_err(|error| error.to_string())?;
7468 let mut outcomes = Vec::new();
7469 for row in rows {
7470 outcomes.push(Self::outcome_from_row(
7471 row.map_err(|error| error.to_string())?,
7472 )?);
7473 }
7474 Ok(outcomes)
7475 }
7476
7477 fn prune_commit_outcomes(&self) -> Result<(), String> {
7478 #[cfg(test)]
7479 self.outcome_prune_count
7480 .set(self.outcome_prune_count.get() + 1);
7481 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
7482 let count = self
7483 .conn
7484 .query_row(
7485 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
7486 [],
7487 |row| row.get::<_, i64>(0),
7488 )
7489 .map_err(|error| error.to_string())? as usize;
7490 let excess = count.saturating_sub(max_entries);
7491 if excess == 0 {
7492 return Ok(());
7493 }
7494 let mut stmt = self
7495 .conn
7496 .prepare(
7497 "SELECT seq FROM _syncular_commit_outcomes
7498 WHERE status IN ('applied', 'cached') OR resolution != 'active'
7499 ORDER BY seq ASC LIMIT ?1",
7500 )
7501 .map_err(|error| error.to_string())?;
7502 let rows = stmt
7503 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
7504 .map_err(|error| error.to_string())?;
7505 let sequences = rows
7506 .collect::<Result<Vec<_>, _>>()
7507 .map_err(|error| error.to_string())?;
7508 drop(stmt);
7509 for sequence in sequences {
7510 self.conn
7511 .execute(
7512 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
7513 rusqlite::params![sequence],
7514 )
7515 .map_err(|error| error.to_string())?;
7516 }
7517 Ok(())
7518 }
7519
7520 pub fn subscribe(
7523 &mut self,
7524 id: String,
7525 table: String,
7526 scopes: Vec<(String, Vec<String>)>,
7527 params: Option<String>,
7528 ) -> Result<(), String> {
7529 if self.schema.table(&table).is_none() {
7530 return Err(format!("unknown table {table:?}"));
7531 }
7532 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
7533 let same_intent = existing.table == table
7534 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
7535 && existing.params == params;
7536 if same_intent {
7537 return Ok(());
7538 }
7539 return Err(
7540 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
7541 .to_owned(),
7542 );
7543 }
7544 let sub = Subscription {
7545 id: id.clone(),
7546 table,
7547 requested: scopes,
7548 params,
7549 cursor: -1,
7550 bootstrap_state: None,
7551 state: SubState::Active,
7552 reason_code: None,
7553 effective: None,
7554 synced_once: false,
7555 };
7556 self.persist_sub(&sub)?;
7557 self.subs.push(sub);
7558 Ok(())
7559 }
7560
7561 pub fn unsubscribe(&mut self, id: &str) {
7562 self.subs.retain(|s| s.id != id);
7563 let _ = self.conn.execute(
7564 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7565 rusqlite::params![id],
7566 );
7567 }
7568
7569 pub fn set_window(
7577 &mut self,
7578 base: &WindowBase,
7579 units: &[String],
7580 ) -> Result<CommandEffects, String> {
7581 let table = self
7582 .schema
7583 .table(&base.table)
7584 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
7585 if table.scope_column(&base.variable).is_none() {
7586 return Err(format!(
7587 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
7588 base.table, base.variable
7589 ));
7590 }
7591 let base_key = window_base_key(base);
7592 let wanted: std::collections::HashSet<&String> = units.iter().collect();
7593 let live = self.load_window_units(&base_key);
7594 self.begin_observation("syncular_window")?;
7595 let mut batch = ChangeAccumulator::default();
7596 let mut changed = false;
7597
7598 let prior_subs = self.subs.clone();
7599 let widened = (|| {
7600 for unit in units {
7602 if live.iter().any(|(u, _)| u == unit) {
7603 continue;
7604 }
7605 let sub_id = derive_sub_id(base, unit);
7606 self.delete_pending_evict(&sub_id)?;
7607 self.insert_window_unit(&base_key, unit, &sub_id)?;
7608 self.subscribe(
7609 sub_id,
7610 base.table.clone(),
7611 unit_scopes(base, unit),
7612 base.params.clone(),
7613 )?;
7614 batch.window(&base_key, &base.table, unit);
7615 changed = true;
7616 }
7617
7618 self.finish_observation("syncular_window", batch)
7619 })();
7620 if let Err(error) = widened {
7621 self.rollback_observation("syncular_window");
7622 self.subs = prior_subs;
7623 return Err(error);
7624 }
7625 for (unit, sub_id) in live {
7626 if wanted.contains(&unit) {
7627 continue;
7628 }
7629 self.rebuild_overlay_if_dirty();
7630 let prior_subs = self.subs.clone();
7631 self.begin_observation("syncular_window")?;
7632 let effective = self
7633 .subs
7634 .iter()
7635 .find(|sub| sub.id == sub_id)
7636 .and_then(|sub| sub.effective.clone())
7637 .unwrap_or_else(|| unit_scopes(base, &unit));
7638 let mut batch = ChangeAccumulator::default();
7639 self.record_scope_map(&mut batch, &base.table, &effective);
7640 batch.window(&base_key, &base.table, &unit);
7641 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
7642 let remaining = match result {
7643 Ok(remaining) => remaining,
7644 Err(error) => {
7645 self.rollback_observation("syncular_window");
7646 self.subs = prior_subs;
7647 return Err(error);
7648 }
7649 };
7650 if let Err(error) = self.finish_observation("syncular_window", batch) {
7651 self.rollback_observation("syncular_window");
7652 self.subs = prior_subs;
7653 return Err(error);
7654 }
7655 if remaining {
7656 self.drain_pending_evictions()?;
7657 }
7658 changed = true;
7659 }
7660 Ok(if changed {
7661 CommandEffects::interactive()
7662 } else {
7663 CommandEffects::none()
7664 })
7665 }
7666
7667 pub fn window_state(&self, base: &WindowBase) -> WindowState {
7672 let mut units = Vec::new();
7673 let mut pending = Vec::new();
7674 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
7675 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
7676 Some(sub) => {
7677 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
7678 }
7679 None => true,
7680 };
7681 if is_pending {
7682 pending.push(unit.clone());
7683 }
7684 units.push(unit);
7685 }
7686 WindowState { units, pending }
7687 }
7688
7689 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
7690 let mut stmt = match self
7691 .conn
7692 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
7693 {
7694 Ok(stmt) => stmt,
7695 Err(_) => return Vec::new(),
7696 };
7697 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
7698 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7699 });
7700 match rows {
7701 Ok(rows) => rows.filter_map(Result::ok).collect(),
7702 Err(_) => Vec::new(),
7703 }
7704 }
7705
7706 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
7707 let mut stmt = match self.conn.prepare(
7708 "SELECT windows.base, windows.unit, subscriptions.tbl
7709 FROM _syncular_windows AS windows
7710 JOIN _syncular_subscriptions AS subscriptions
7711 ON subscriptions.id = windows.sub_id
7712 ORDER BY windows.base, windows.unit",
7713 ) {
7714 Ok(stmt) => stmt,
7715 Err(_) => return Vec::new(),
7716 };
7717 let rows = stmt.query_map([], |row| {
7718 Ok((
7719 row.get::<_, String>(0)?,
7720 row.get::<_, String>(1)?,
7721 row.get::<_, String>(2)?,
7722 ))
7723 });
7724 match rows {
7725 Ok(rows) => rows.filter_map(Result::ok).collect(),
7726 Err(_) => Vec::new(),
7727 }
7728 }
7729
7730 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
7731 self.conn
7732 .query_row(
7733 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
7734 rusqlite::params![sub_id],
7735 |row| Ok((row.get(0)?, row.get(1)?)),
7736 )
7737 .ok()
7738 }
7739
7740 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
7741 self.conn
7742 .execute(
7743 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
7744 rusqlite::params![base_key, unit, sub_id],
7745 )
7746 .map(|_| ())
7747 .map_err(|error| error.to_string())
7748 }
7749
7750 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
7751 self.conn
7752 .execute(
7753 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
7754 rusqlite::params![base_key, unit],
7755 )
7756 .map(|_| ())
7757 .map_err(|error| error.to_string())
7758 }
7759
7760 fn evict_unit(
7767 &mut self,
7768 base_key: &str,
7769 base: &WindowBase,
7770 unit: &str,
7771 sub_id: &str,
7772 ) -> Result<bool, String> {
7773 let effective = self
7774 .subs
7775 .iter()
7776 .find(|s| s.id == sub_id)
7777 .and_then(|s| s.effective.clone())
7778 .unwrap_or_else(|| unit_scopes(base, unit));
7779 let pinned = self.pinned_row_ids(&base.table);
7780 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
7781 self.delete_window_unit(base_key, unit)?;
7782 self.conn
7783 .execute(
7784 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7785 [sub_id],
7786 )
7787 .map_err(|error| error.to_string())?;
7788 self.subs.retain(|sub| sub.id != sub_id);
7789 if deferred {
7790 self.save_pending_evict(sub_id, &base.table, &effective)?;
7791 } else {
7792 self.delete_pending_evict(sub_id)?;
7793 }
7794 Ok(remaining)
7795 }
7796
7797 fn evict_scope_rows(
7801 &self,
7802 table_name: &str,
7803 effective: &[(String, Vec<String>)],
7804 pinned: &std::collections::HashSet<String>,
7805 ) -> Result<(bool, bool), String> {
7806 if effective.is_empty() {
7807 return Ok((false, false));
7808 }
7809 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
7810 let mut clauses = Vec::new();
7811 let mut params: Vec<SqlValue> = Vec::new();
7812 for (variable, values) in effective {
7813 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
7814 if values.is_empty() {
7815 return Ok((false, false));
7816 }
7817 let holes = values
7818 .iter()
7819 .map(|v| {
7820 params.push(SqlValue::Text(v.clone()));
7821 "?"
7822 })
7823 .collect::<Vec<_>>()
7824 .join(", ");
7825 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
7826 }
7827 let scope = clauses.join(" AND ");
7828 let scope_params = params.len();
7829 let pk = quote_ident(&table.primary_key);
7830 if !pinned.is_empty() {
7831 let holes = pinned
7832 .iter()
7833 .map(|id| {
7834 params.push(SqlValue::Text(id.clone()));
7835 "?"
7836 })
7837 .collect::<Vec<_>>()
7838 .join(", ");
7839 clauses.push(format!("{pk} NOT IN ({holes})"));
7840 }
7841 let predicate = clauses.join(" AND ");
7842 let base = base_table(table_name);
7843 let visible = visible_table(table_name);
7844 let mut ids: Vec<SqlValue> = Vec::new();
7845 for source in [&base, &visible] {
7846 let mut stmt = self
7847 .conn
7848 .prepare_cached(&format!(
7849 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
7850 ))
7851 .map_err(|e| e.to_string())?;
7852 ids = stmt
7853 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
7854 .map_err(|e| e.to_string())?
7855 .collect::<Result<_, _>>()
7856 .map_err(|e| e.to_string())?;
7857 if !ids.is_empty() {
7858 break;
7859 }
7860 }
7861 if !ids.is_empty() {
7862 let holes = vec!["?"; ids.len()].join(", ");
7863 for source in [&base, &visible] {
7864 self.conn
7865 .execute(
7866 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
7867 rusqlite::params_from_iter(&ids),
7868 )
7869 .map_err(|e| e.to_string())?;
7870 }
7871 }
7872 let mut remaining = false;
7873 let mut deferred = false;
7874 for source in [&base, &visible] {
7875 remaining |= self
7876 .conn
7877 .query_row(
7878 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
7879 rusqlite::params_from_iter(¶ms),
7880 |row| row.get::<_, bool>(0),
7881 )
7882 .map_err(|e| e.to_string())?;
7883 deferred |= self
7884 .conn
7885 .query_row(
7886 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
7887 rusqlite::params_from_iter(¶ms[..scope_params]),
7888 |row| row.get::<_, bool>(0),
7889 )
7890 .map_err(|e| e.to_string())?;
7891 }
7892 Ok((remaining, deferred))
7893 }
7894
7895 fn drain_pending_evictions(&mut self) -> Result<(), String> {
7897 let pending = self.load_pending_evictions()?;
7898 if pending.is_empty() {
7899 return Ok(());
7900 }
7901 self.rebuild_overlay_if_dirty();
7902 for (sub_id, table_name, effective) in pending {
7903 if self.schema.table(&table_name).is_none() {
7904 self.delete_pending_evict(&sub_id)?;
7905 continue;
7906 }
7907 loop {
7908 std::thread::yield_now();
7909 self.begin_observation("syncular_evict")?;
7910 let pinned = self.pinned_row_ids(&table_name);
7911 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
7912 let (remaining, deferred) = match result {
7913 Ok(result) => result,
7914 Err(error) => {
7915 self.rollback_observation("syncular_evict");
7916 return Err(error);
7917 }
7918 };
7919 if !deferred {
7920 if let Err(error) = self.delete_pending_evict(&sub_id) {
7921 self.rollback_observation("syncular_evict");
7922 return Err(error);
7923 }
7924 }
7925 let mut batch = ChangeAccumulator::default();
7926 self.record_scope_map(&mut batch, &table_name, &effective);
7927 if let Err(error) = self.finish_observation("syncular_evict", batch) {
7928 self.rollback_observation("syncular_evict");
7929 return Err(error);
7930 }
7931 if !remaining {
7932 break;
7933 }
7934 }
7935 }
7936 Ok(())
7937 }
7938
7939 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
7942 let mut pinned = std::collections::HashSet::new();
7943 for commit in &self.outbox {
7944 for op in &commit.ops {
7945 if op.table == table {
7946 pinned.insert(op.row_id.clone());
7947 }
7948 }
7949 }
7950 pinned
7951 }
7952
7953 fn save_pending_evict(
7954 &self,
7955 sub_id: &str,
7956 table: &str,
7957 effective: &[(String, Vec<String>)],
7958 ) -> Result<(), String> {
7959 self.conn.execute(
7960 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
7961 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
7962 ).map(|_| ()).map_err(|error| error.to_string())
7963 }
7964
7965 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
7966 self.conn
7967 .execute(
7968 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
7969 rusqlite::params![sub_id],
7970 )
7971 .map(|_| ())
7972 .map_err(|error| error.to_string())
7973 }
7974
7975 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
7976 let mut stmt = self
7977 .conn
7978 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
7979 .map_err(|error| error.to_string())?;
7980 let rows = stmt
7981 .query_map([], |row| {
7982 Ok((
7983 row.get::<_, String>(0)?,
7984 row.get::<_, String>(1)?,
7985 row.get::<_, String>(2)?,
7986 ))
7987 })
7988 .map_err(|error| error.to_string())?;
7989 rows.map(|row| {
7990 let (sub_id, table, json) = row.map_err(|error| error.to_string())?;
7991 let value: Value = serde_json::from_str(&json)
7992 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7993 let effective = json_to_scope_map(&value)
7994 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7995 Ok((sub_id, table, effective))
7996 })
7997 .collect()
7998 }
7999
8000 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
8002 if mutations.is_empty() {
8003 return Err("a commit must contain at least one operation (§6.1)".to_owned());
8004 }
8005 let mut ops = Vec::with_capacity(mutations.len());
8006 for mutation in mutations {
8007 match mutation {
8008 Mutation::Upsert {
8009 table,
8010 values,
8011 base_version,
8012 } => {
8013 let schema_table = self
8014 .schema
8015 .table(&table)
8016 .ok_or_else(|| format!("unknown table {table:?}"))?;
8017 let values = normalize_values_casing(schema_table, values)?;
8021 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
8022 let values = full_row_values(schema_table, values)?;
8025 self.validate_author_encode(schema_table, &row_id, &values)?;
8031 ops.push(OutboxOp {
8032 upsert: true,
8033 table,
8034 row_id,
8035 base_version,
8036 values: Some(values),
8037 });
8038 }
8039 Mutation::Delete {
8040 table,
8041 row_id,
8042 base_version,
8043 } => {
8044 if self.schema.table(&table).is_none() {
8045 return Err(format!("unknown table {table:?}"));
8046 }
8047 ops.push(OutboxOp {
8048 upsert: false,
8049 table,
8050 row_id,
8051 base_version,
8052 values: None,
8053 });
8054 }
8055 }
8056 }
8057 self.record_outbox_commit(ops)
8058 }
8059
8060 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
8061 let commit = OutboxCommit {
8062 client_commit_id: uuid::Uuid::new_v4().to_string(),
8063 ops,
8064 };
8065 self.begin_observation("syncular_mutation")?;
8066 let mut batch = ChangeAccumulator::default();
8067 for op in &commit.ops {
8068 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
8069 if let Some(values) = &op.values {
8070 if let Some(table) = self.schema.table(&op.table) {
8071 for scope in &table.scope_variables {
8072 if let Some(Value::String(value)) = values.get(&scope.column) {
8073 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
8074 precise = true;
8075 }
8076 }
8077 }
8078 }
8079 if !precise {
8080 batch.table(&op.table);
8081 }
8082 }
8083 if let Err(error) = self.persist_outbox_insert(&commit) {
8084 self.rollback_observation("syncular_mutation");
8085 return Err(error);
8086 }
8087 let was_dirty = self.overlay_dirty.get();
8088 if was_dirty {
8093 self.rebuild_overlay();
8094 }
8095 if let Some(error) = commit
8096 .ops
8097 .iter()
8098 .find_map(|op| self.apply_outbox_op(op).err())
8099 {
8100 self.rollback_observation("syncular_mutation");
8101 self.overlay_dirty.set(was_dirty);
8102 return Err(error);
8103 }
8104 let id = commit.client_commit_id.clone();
8105 self.outbox.push(commit);
8106 batch.status = true;
8107 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
8108 self.rollback_observation("syncular_mutation");
8109 self.outbox.pop();
8110 self.overlay_dirty.set(was_dirty);
8111 return Err(error);
8112 }
8113 Ok(id)
8114 }
8115
8116 pub fn patch(
8122 &mut self,
8123 table: &str,
8124 row_id: &str,
8125 partial: Map<String, Value>,
8126 base_version: Option<i64>,
8127 ) -> Result<String, String> {
8128 let schema_table = self
8129 .schema
8130 .table(table)
8131 .ok_or_else(|| format!("unknown table {table:?}"))?;
8132 let partial = normalize_values_casing(schema_table, partial)?;
8133 if let Some(pk) = partial.get(&schema_table.primary_key) {
8134 if pk.as_str() != Some(row_id) {
8135 return Err(format!(
8136 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
8137 ));
8138 }
8139 }
8140 for scope in &schema_table.scope_variables {
8150 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
8151 continue;
8152 }
8153 let Some(column) = schema_table
8154 .columns
8155 .iter()
8156 .find(|column| column.name == scope.column)
8157 else {
8158 continue;
8159 };
8160 let stored = self
8161 .conn
8162 .query_row(
8163 &format!(
8164 "SELECT {} FROM {} WHERE {}",
8165 quote_ident(&scope.column),
8166 visible_table(&schema_table.name),
8167 row_id_predicate(schema_table)
8168 ),
8169 rusqlite::params![row_id],
8170 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
8171 )
8172 .ok();
8173 let incoming = json_to_column_value(column, partial.get(&scope.column))
8178 .ok()
8179 .map(|value| column_value_to_json(&value));
8180 let equal = stored.as_ref().is_some_and(|stored| {
8184 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
8185 });
8186 if !equal {
8187 return Err(format!(
8188 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
8189 scope.column
8190 ));
8191 }
8192 }
8193 let mut values = partial;
8194 for scope in &schema_table.scope_variables {
8197 values.remove(&scope.column);
8198 }
8199 values.insert(
8200 schema_table.primary_key.clone(),
8201 Value::from(row_id.to_owned()),
8202 );
8203 self.validate_author_encode(schema_table, row_id, &values)?;
8209 self.record_outbox_commit(vec![OutboxOp {
8210 upsert: true,
8211 table: table.to_owned(),
8212 row_id: row_id.to_owned(),
8213 base_version,
8214 values: Some(values),
8215 }])
8216 }
8217
8218 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
8223 let selector = self.encryption.key_id_columns.get(&table.name)?;
8224 let index = table
8225 .columns
8226 .iter()
8227 .position(|column| &column.name == selector)?;
8228 let sql = format!(
8229 "SELECT {} FROM {} WHERE {}",
8230 quote_ident(selector),
8231 visible_table(&table.name),
8232 row_id_predicate(table)
8233 );
8234 let key: Option<String> = self
8235 .conn
8236 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
8237 .ok()?;
8238 let key = key.filter(|key| !key.is_empty())?;
8239 let mut fallback: Row = vec![None; table.columns.len()];
8240 fallback[index] = Some(ColumnValue::String(key));
8241 Some(fallback)
8242 }
8243
8244 fn validate_author_encode(
8249 &self,
8250 table: &TableSchema,
8251 row_id: &str,
8252 values: &Map<String, Value>,
8253 ) -> Result<(), String> {
8254 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
8255 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
8256 result => result.map(|_| ()),
8257 }
8258 }
8259
8260 fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
8266 if !self
8267 .schema
8268 .tables
8269 .iter()
8270 .any(|table| table.has_encrypted_columns())
8271 {
8272 return Ok(false);
8273 }
8274 let failures = self
8275 .outbox
8276 .iter()
8277 .filter_map(|commit| {
8278 commit
8279 .ops
8280 .iter()
8281 .find_map(|op| {
8282 let values = op.values.as_ref().filter(|_| op.upsert)?;
8283 let table = self.schema.table(&op.table)?;
8284 let fallback = self.stored_key_fallback(table, &op.row_id);
8285 match encode_sparse_row_json(
8286 table,
8287 &op.row_id,
8288 values,
8289 &self.encryption,
8290 fallback.as_ref(),
8291 ) {
8292 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
8293 _ => None,
8294 }
8295 })
8296 .map(|error| (commit.clone(), error))
8297 })
8298 .collect::<Vec<_>>();
8299 if failures.is_empty() {
8300 return Ok(false);
8301 }
8302 for (commit, message) in failures {
8303 let rejection = RejectionRecord {
8304 client_commit_id: commit.client_commit_id.clone(),
8305 op_index: 0,
8306 code: ENCRYPT_FAILED_CODE.to_owned(),
8307 message,
8308 retryable: false,
8309 details: None,
8310 operation: commit.ops.first().map(CommitOperation::from),
8311 };
8312 self.persist_commit_outcome(
8313 &commit.client_commit_id,
8314 CommitOutcomeStatus::Rejected,
8315 &[CommitOperationOutcome::Error {
8316 rejection: rejection.clone(),
8317 }],
8318 Some(&commit.ops),
8319 )?;
8320 self.delete_outbox_persisted(&commit.client_commit_id)?;
8321 self.outbox
8322 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
8323 self.rejections.push(rejection);
8324 }
8325 self.prune_commit_outcomes()?;
8326 self.overlay_dirty.set(true);
8327 self.rebuild_overlay();
8328 Ok(true)
8329 }
8330
8331 pub fn pending_commit_ids(&self) -> Vec<String> {
8332 self.outbox
8333 .iter()
8334 .map(|c| c.client_commit_id.clone())
8335 .collect()
8336 }
8337
8338 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
8342 let mut payloads = Vec::new();
8343 for commit in &self.outbox {
8344 for op in &commit.ops {
8345 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
8346 continue;
8347 };
8348 let Some(table) = self.schema.table(&op.table) else {
8349 continue;
8350 };
8351 let fallback = self.stored_key_fallback(table, &op.row_id);
8352 if let Ok(payload) = encode_sparse_row_json(
8353 table,
8354 &op.row_id,
8355 values,
8356 &self.encryption,
8357 fallback.as_ref(),
8358 ) {
8359 payloads.push(payload);
8360 }
8361 }
8362 }
8363 payloads
8364 }
8365
8366 pub fn conflicts(&self) -> &[ConflictRecord] {
8367 &self.conflicts
8368 }
8369
8370 pub fn rejections(&self) -> &[RejectionRecord] {
8371 &self.rejections
8372 }
8373
8374 pub fn resolve_commit_outcome(
8375 &mut self,
8376 input: ResolveCommitOutcomeInput,
8377 ) -> Result<CommitOutcome, String> {
8378 let current = self
8379 .commit_outcome(&input.client_commit_id)?
8380 .ok_or_else(|| {
8381 format!(
8382 "sync.outcome_not_found: no durable outcome exists for {:?}",
8383 input.client_commit_id
8384 )
8385 })?;
8386 if current.resolution != CommitOutcomeResolution::Active {
8387 return Ok(current);
8388 }
8389 if input.resolution == CommitOutcomeResolution::Active {
8390 return Err("sync.invalid_request: resolution must leave active state".to_owned());
8391 }
8392 match input.resolution {
8393 CommitOutcomeResolution::Superseded => {
8394 let replacement = input
8395 .replacement_client_commit_id
8396 .as_deref()
8397 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
8398 .ok_or_else(|| {
8399 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
8400 .to_owned()
8401 })?;
8402 let _ = replacement;
8403 }
8404 _ if input.replacement_client_commit_id.is_some() => {
8405 return Err(
8406 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
8407 .to_owned(),
8408 );
8409 }
8410 _ => {}
8411 }
8412 let allowed = match current.status {
8413 CommitOutcomeStatus::Conflict => matches!(
8414 input.resolution,
8415 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
8416 ),
8417 CommitOutcomeStatus::Rejected => {
8418 input.resolution == CommitOutcomeResolution::Superseded
8419 }
8420 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
8421 input.resolution == CommitOutcomeResolution::Dismissed
8422 }
8423 };
8424 if !allowed {
8425 return Err(format!(
8426 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
8427 input.resolution, current.status
8428 ));
8429 }
8430
8431 self.begin_observation("syncular_outcome_resolution")?;
8432 let result = (|| {
8433 self.conn
8434 .execute(
8435 "UPDATE _syncular_commit_outcomes
8436 SET resolution = ?1, resolved_at_ms = ?2,
8437 replacement_client_commit_id = ?3
8438 WHERE client_commit_id = ?4 AND resolution = 'active'",
8439 rusqlite::params![
8440 Self::outcome_resolution_name(input.resolution),
8441 self.clock_now_ms(),
8442 input.replacement_client_commit_id,
8443 input.client_commit_id
8444 ],
8445 )
8446 .map_err(|error| error.to_string())?;
8447 let resolved = self
8448 .commit_outcome(¤t.client_commit_id)?
8449 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
8450 self.prune_commit_outcomes()?;
8451 Ok(resolved)
8452 })();
8453 let resolved = match result {
8454 Ok(outcome) => outcome,
8455 Err(error) => {
8456 self.rollback_observation("syncular_outcome_resolution");
8457 return Err(error);
8458 }
8459 };
8460 self.conflicts
8461 .retain(|record| record.client_commit_id != current.client_commit_id);
8462 self.rejections
8463 .retain(|record| record.client_commit_id != current.client_commit_id);
8464 let batch = ChangeAccumulator {
8465 conflicts: current.status == CommitOutcomeStatus::Conflict,
8466 rejections: current.status == CommitOutcomeStatus::Rejected,
8467 outcomes: true,
8468 ..ChangeAccumulator::default()
8469 };
8470 if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
8471 self.rollback_observation("syncular_outcome_resolution");
8472 return Err(error);
8473 }
8474 Ok(resolved)
8475 }
8476
8477 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
8478 self.schema_floor.as_ref()
8479 }
8480
8481 pub fn lease_state(&self) -> Option<&LeaseState> {
8483 self.lease_state.as_ref()
8484 }
8485
8486 fn record_lease_error(&mut self, code: &str) {
8489 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
8490 return;
8491 }
8492 let mut next = self.lease_state.clone().unwrap_or_default();
8493 next.error_code = Some(code.to_owned());
8494 self.set_lease_state(Some(next));
8495 }
8496
8497 fn set_lease_state(&mut self, next: Option<LeaseState>) {
8498 if self.lease_state == next {
8499 return;
8500 }
8501 if self.begin_observation("syncular_lease").is_err() {
8502 return;
8503 }
8504 self.lease_state = next;
8505 if let Some(lease) = &self.lease_state {
8506 if let Ok(json) = serde_json::to_string(lease) {
8507 self.set_meta(LEASE_STATE_KEY, &json);
8508 }
8509 } else {
8510 self.delete_meta(LEASE_STATE_KEY);
8511 }
8512 let batch = ChangeAccumulator {
8513 status: true,
8514 ..ChangeAccumulator::default()
8515 };
8516 if self.finish_observation("syncular_lease", batch).is_err() {
8517 self.rollback_observation("syncular_lease");
8518 }
8519 }
8520
8521 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
8522 if self.schema_floor == next {
8523 return;
8524 }
8525 if self.begin_observation("syncular_schema_floor").is_err() {
8526 return;
8527 }
8528 self.schema_floor = next;
8529 self.stopped = self.schema_floor.is_some();
8530 if let Some(floor) = &self.schema_floor {
8531 if let Ok(json) = serde_json::to_string(floor) {
8532 self.set_meta(SCHEMA_FLOOR_KEY, &json);
8533 }
8534 } else {
8535 self.delete_meta(SCHEMA_FLOOR_KEY);
8536 }
8537 let batch = ChangeAccumulator {
8538 status: true,
8539 ..ChangeAccumulator::default()
8540 };
8541 if self
8542 .finish_observation("syncular_schema_floor", batch)
8543 .is_err()
8544 {
8545 self.rollback_observation("syncular_schema_floor");
8546 }
8547 }
8548
8549 fn set_upgrading(&mut self, value: bool) {
8550 if self.upgrading == value {
8551 return;
8552 }
8553 if self.begin_observation("syncular_upgrading").is_err() {
8554 return;
8555 }
8556 self.upgrading = value;
8557 let batch = ChangeAccumulator {
8558 status: true,
8559 ..ChangeAccumulator::default()
8560 };
8561 if self
8562 .finish_observation("syncular_upgrading", batch)
8563 .is_err()
8564 {
8565 self.rollback_observation("syncular_upgrading");
8566 }
8567 }
8568
8569 pub fn sync_needed(&self) -> bool {
8570 self.sync_needed
8571 }
8572
8573 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
8574 let sub = self.subs.iter().find(|s| s.id == id)?;
8575 Some(SubscriptionStateView {
8576 id: sub.id.clone(),
8577 table: sub.table.clone(),
8578 status: sub.state.name().to_owned(),
8579 cursor: sub.cursor,
8580 has_resume_token: sub.bootstrap_state.is_some(),
8581 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
8582 reason_code: sub.reason_code.clone(),
8583 })
8584 }
8585
8586 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
8587 let schema_table = self
8588 .schema
8589 .table(table)
8590 .ok_or_else(|| format!("unknown table {table:?}"))?;
8591 let sql = format!(
8592 "SELECT * FROM {} ORDER BY {} ASC",
8593 visible_table(table),
8594 quote_ident(&schema_table.primary_key)
8595 );
8596 let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
8597 let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
8598 let mut out = Vec::new();
8599 while let Some(row) = rows.next().map_err(|e| e.to_string())? {
8600 let mut values = Map::new();
8601 for (i, column) in schema_table.columns.iter().enumerate() {
8602 let value = row.get_ref(i).map_err(|e| e.to_string())?;
8603 values.insert(column.name.clone(), sql_ref_to_json(column, value));
8604 }
8605 let version: i64 = row
8606 .get(schema_table.columns.len())
8607 .map_err(|e| e.to_string())?;
8608 let row_id = match values.get(&schema_table.primary_key) {
8609 Some(Value::String(s)) => s.clone(),
8610 Some(Value::Number(n)) => n.to_string(),
8611 Some(Value::Bool(b)) => b.to_string(),
8612 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
8613 };
8614 out.push(RowState {
8615 row_id,
8616 version,
8617 values,
8618 });
8619 }
8620 Ok(out)
8621 }
8622
8623 #[cfg(feature = "crdt-yjs")]
8639 fn crdt_column_bytes(
8640 &self,
8641 table: &str,
8642 row_id: &str,
8643 column: &str,
8644 ) -> Result<Option<Vec<u8>>, String> {
8645 let schema_table = self
8646 .schema
8647 .table(table)
8648 .ok_or_else(|| format!("unknown table {table:?}"))?;
8649 let col = schema_table
8650 .columns
8651 .iter()
8652 .find(|c| c.name == column)
8653 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
8654 if col.ty != ColumnType::Crdt {
8655 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
8656 }
8657 let sql = format!(
8658 "SELECT {} FROM {} WHERE {}",
8659 quote_ident(column),
8660 visible_table(table),
8661 row_id_predicate(schema_table)
8662 );
8663 let bytes: Option<Vec<u8>> = self
8664 .conn
8665 .prepare_cached(&sql)
8666 .map_err(|error| error.to_string())?
8667 .query_row(rusqlite::params![row_id], |row| {
8668 row.get::<_, Option<Vec<u8>>>(0)
8669 })
8670 .map_err(|e| match e {
8671 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
8672 other => other.to_string(),
8673 })?;
8674 Ok(bytes)
8675 }
8676
8677 #[cfg(feature = "crdt-yjs")]
8681 pub fn crdt_text(
8682 &self,
8683 table: &str,
8684 row_id: &str,
8685 column: &str,
8686 name: &str,
8687 ) -> Result<String, String> {
8688 let bytes = self
8689 .crdt_column_bytes(table, row_id, column)?
8690 .unwrap_or_default();
8691 crate::crdt::text(&bytes, name)
8692 }
8693
8694 #[cfg(feature = "crdt-yjs")]
8698 pub fn crdt_insert_text(
8699 &mut self,
8700 table: &str,
8701 row_id: &str,
8702 column: &str,
8703 name: &str,
8704 index: u32,
8705 value: &str,
8706 ) -> Result<String, String> {
8707 let current = self
8708 .crdt_column_bytes(table, row_id, column)?
8709 .unwrap_or_default();
8710 let update = crate::crdt::insert_text(¤t, name, index, value)?;
8711 self.crdt_push_update(table, row_id, column, &update)
8712 }
8713
8714 #[cfg(feature = "crdt-yjs")]
8718 pub fn crdt_delete_text(
8719 &mut self,
8720 table: &str,
8721 row_id: &str,
8722 column: &str,
8723 name: &str,
8724 index: u32,
8725 len: u32,
8726 ) -> Result<String, String> {
8727 let current = self
8728 .crdt_column_bytes(table, row_id, column)?
8729 .unwrap_or_default();
8730 let update = crate::crdt::delete_text(¤t, name, index, len)?;
8731 self.crdt_push_update(table, row_id, column, &update)
8732 }
8733
8734 #[cfg(feature = "crdt-yjs")]
8739 pub fn crdt_apply_update(
8740 &mut self,
8741 table: &str,
8742 row_id: &str,
8743 column: &str,
8744 update: &[u8],
8745 ) -> Result<String, String> {
8746 let current = self
8747 .crdt_column_bytes(table, row_id, column)?
8748 .unwrap_or_default();
8749 let next = crate::crdt::apply_update(¤t, update)?;
8750 self.crdt_push_update(table, row_id, column, &next)
8751 }
8752
8753 #[cfg(feature = "crdt-yjs")]
8760 fn crdt_push_update(
8761 &mut self,
8762 table: &str,
8763 row_id: &str,
8764 column: &str,
8765 crdt_bytes: &[u8],
8766 ) -> Result<String, String> {
8767 let mut bytes_obj = Map::new();
8768 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
8769 let mut partial = Map::new();
8770 partial.insert(column.to_owned(), Value::Object(bytes_obj));
8771 self.patch(table, row_id, partial, None)
8772 }
8773
8774 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
8789 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
8790 }
8791
8792 #[cfg(feature = "bench-internals")]
8794 #[doc(hidden)]
8795 pub fn benchmark_connection(&mut self) -> &mut Connection {
8796 &mut self.conn
8797 }
8798
8799 #[cfg(feature = "bench-internals")]
8801 #[doc(hidden)]
8802 pub fn benchmark_phases(
8803 &self,
8804 enabled: Option<bool>,
8805 reset: bool,
8806 ) -> Result<Option<Value>, String> {
8807 self.benchmark_phases.configure(enabled, reset)?;
8808 Ok(self.benchmark_phases.snapshot())
8809 }
8810
8811 pub fn query_snapshot(
8815 &mut self,
8816 sql: &str,
8817 params: &[QueryValue],
8818 coverage: &[WindowCoverage],
8819 owner: Option<QueryOwner<'_>>,
8820 ) -> Result<QuerySnapshot, String> {
8821 let result = snapshot_connection(&self.conn, sql, params, coverage);
8822 if let Some(owner) = owner {
8823 self.record_query_read(owner, result.as_ref().err());
8824 }
8825 result.map_err(|failure| failure.to_string())
8826 }
8827
8828 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
8832 let position = self
8833 .query_failures
8834 .iter()
8835 .position(|entry| entry.id == owner.id);
8836 let Some(failure) = failure else {
8837 if let Some(index) = position {
8838 self.query_failures.remove(index);
8839 }
8840 return;
8841 };
8842 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
8843 .into_iter()
8844 .map(str::to_owned)
8845 .collect();
8846 let code = failure.code.unwrap_or("client.query_failed").to_owned();
8847 if position.is_some_and(|index| {
8848 let entry = &self.query_failures[index];
8849 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
8850 }) {
8851 return;
8852 }
8853 if let Some(index) = position {
8854 self.query_failures.remove(index);
8855 }
8856 self.query_failures.push(DiagnosticQueryFailure {
8857 id: owner.id.to_owned(),
8858 tables,
8859 code,
8860 sqlite_code: failure.sqlite_code,
8861 at_ms: self.clock_now_ms(),
8862 });
8863 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
8864 self.query_failures.remove(0);
8865 }
8866 }
8867
8868 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
8871 #[cfg(feature = "bench-internals")]
8872 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
8873 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
8874 let mut frames = vec![Frame::ReqHeader {
8875 client_id: self.client_id.clone(),
8876 schema_version: self.schema.version,
8877 log_epoch: log_epoch.clone(),
8878 }];
8879 let mut pushed_ids = Vec::new();
8880 let mut ops_in_request = 0usize;
8881 let mut deferred_commits = 0usize;
8882 #[cfg(feature = "bench-internals")]
8883 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
8884 for (index, commit) in self
8885 .outbox
8886 .iter()
8887 .take(if log_epoch.is_some() {
8888 self.outbox.len()
8889 } else {
8890 0
8891 })
8892 .enumerate()
8893 {
8894 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
8899 deferred_commits = self.outbox.len() - index;
8900 break;
8901 }
8902 ops_in_request += commit.ops.len();
8903 let operations = commit
8904 .ops
8905 .iter()
8906 .map(|op| {
8907 let payload = op.values.as_ref().and_then(|values| {
8908 let table = self.schema.table(&op.table)?;
8909 let fallback = self.stored_key_fallback(table, &op.row_id);
8918 encode_sparse_row_json(
8919 table,
8920 &op.row_id,
8921 values,
8922 &self.encryption,
8923 fallback.as_ref(),
8924 )
8925 .ok()
8926 });
8927 ssp2::model::Operation {
8928 table: op.table.clone(),
8929 row_id: op.row_id.clone(),
8930 op: if op.upsert { Op::Upsert } else { Op::Delete },
8931 base_version: op.base_version,
8932 payload,
8933 }
8934 })
8935 .collect();
8936 frames.push(Frame::PushCommit {
8937 client_commit_id: commit.client_commit_id.clone(),
8938 operations,
8939 });
8940 pushed_ids.push(commit.client_commit_id.clone());
8941 }
8942 #[cfg(feature = "bench-internals")]
8943 drop(outbox_phase);
8944 let accept = self.limits.accept.unwrap_or(if url_capable {
8947 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
8948 } else {
8949 DEFAULT_ACCEPT
8950 });
8951 frames.push(Frame::PullHeader {
8952 limit_commits: self.limits.limit_commits.unwrap_or(0),
8953 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
8954 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
8955 accept,
8956 });
8957 let mut fresh = Vec::new();
8958 for sub in &self.subs {
8959 if sub.state != SubState::Active {
8960 continue;
8961 }
8962 let mut scopes = sub.requested.clone();
8963 sort_scope_map(&mut scopes);
8964 frames.push(Frame::Subscription {
8965 id: sub.id.clone(),
8966 table: sub.table.clone(),
8967 scopes,
8968 params: sub.params.clone().map(RawJson),
8969 cursor: sub.cursor,
8970 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
8971 });
8972 fresh.push((
8973 sub.id.clone(),
8974 sub.cursor < 0 && sub.bootstrap_state.is_none(),
8975 ));
8976 }
8977 let message = Message {
8978 wire_version: WIRE_VERSION,
8979 msg_kind: MsgKind::Request,
8980 frames,
8981 };
8982 (
8983 message,
8984 RequestMeta {
8985 pushed_ids,
8986 fresh,
8987 accept,
8988 deferred_commits,
8989 },
8990 )
8991 }
8992
8993 pub fn progress(&self) -> ProgressObserver {
8997 self.progress.clone()
8998 }
8999
9000 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9001 self.progress.start();
9002 let started_at_ms = self.clock_now_ms();
9003 let outcome = self.sync_inner(transport);
9004 self.progress.update(|p| match &outcome {
9005 SyncOutcome::Ok(report) if report.failed.is_empty() => {
9006 p.state = ProgressState::Complete
9007 }
9008 SyncOutcome::Ok(_) => {
9009 p.state = ProgressState::Failed;
9010 p.error_code = Some("sync.scope_revoked".into());
9011 }
9012 SyncOutcome::Failed { error_code, .. } => {
9013 p.state = ProgressState::Failed;
9014 p.error_code = Some(Self::diagnostic_code(error_code));
9015 }
9016 SyncOutcome::RealtimeUnavailable { .. } => {
9017 p.state = ProgressState::Failed;
9018 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
9019 }
9020 });
9021 let completed_at_ms = self.clock_now_ms();
9022 self.last_round = Some(match &outcome {
9023 SyncOutcome::Ok(report) => DiagnosticLastRound {
9024 status: "succeeded".to_owned(),
9025 started_at_ms,
9026 completed_at_ms,
9027 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9028 counters: Some(DiagnosticRoundCounters {
9029 pushed: report.pushed,
9030 applied: report.applied.len(),
9031 rejected: report.rejected.len(),
9032 retryable: report.retryable.len(),
9033 conflicts: report.conflicts,
9034 commits_applied: report.commits_applied,
9035 segment_rows_applied: report.segment_rows_applied,
9036 bootstrapping: report.bootstrapping.len(),
9037 resets: report.resets.len(),
9038 revoked: report.revoked.len(),
9039 failed: report.failed.len(),
9040 deferred_commits: report.deferred_commits,
9041 }),
9042 error_code: None,
9043 },
9044 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
9045 status: "failed".to_owned(),
9046 started_at_ms,
9047 completed_at_ms,
9048 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9049 counters: None,
9050 error_code: Some(Self::diagnostic_code(error_code)),
9051 },
9052 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
9053 status: "failed".to_owned(),
9054 started_at_ms,
9055 completed_at_ms,
9056 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9057 counters: None,
9058 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
9059 },
9060 });
9061 outcome
9062 }
9063
9064 fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9065 if self.stopped {
9066 return SyncOutcome::Ok(SyncReport {
9069 schema_floor: self.schema_floor.clone(),
9070 ..SyncReport::default()
9071 });
9072 }
9073 self.set_sync_needed(false, false);
9076 if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
9079 if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
9080 if Self::retryable_failure_code(&code) {
9081 self.schedule_background_retry();
9082 }
9083 return SyncOutcome::Failed {
9084 error_code: code,
9085 message,
9086 };
9087 }
9088 }
9089 if let Err(message) = self.drain_pending_evictions() {
9090 return SyncOutcome::Failed {
9091 error_code: "storage.failed".into(),
9092 message,
9093 };
9094 }
9095 if let Err(message) = self.drop_unencodable_outbox() {
9099 return SyncOutcome::Failed {
9100 error_code: "storage.failed".into(),
9101 message,
9102 };
9103 }
9104 let (message, meta) = self.build_request(transport.supports_url_fetch());
9105 let request_bytes = {
9106 #[cfg(feature = "bench-internals")]
9107 let _phase = self.benchmark_phases.start(Phase::RequestEncode);
9108 encode_message(&message)
9109 };
9110 let round = if self.realtime_state == RealtimeState::Connected {
9117 transport.realtime_sync(&request_bytes)
9118 } else if self.realtime_policy == RealtimePolicy::Required {
9119 let retry_delay_ms = self.schedule_background_retry();
9120 self.realtime_retry_delay_ms = Some(retry_delay_ms);
9121 return SyncOutcome::RealtimeUnavailable {
9122 state: self.realtime_state,
9123 reason_code: self.realtime_reason_code.clone(),
9124 retry_delay_ms,
9125 };
9126 } else {
9127 transport.sync(&request_bytes)
9128 };
9129 let realtime_round = self.realtime_state == RealtimeState::Connected;
9130 let response_bytes = match round {
9131 Ok(bytes) => bytes,
9132 Err(TransportError { code, message }) => {
9133 if realtime_round {
9137 self.set_realtime_state(RealtimeState::Lost, Some(&code));
9138 }
9139 self.record_lease_error(&code);
9142 if Self::retryable_failure_code(&code) {
9143 let retry_delay_ms = self.schedule_background_retry();
9144 if realtime_round {
9145 self.realtime_retry_delay_ms = Some(retry_delay_ms);
9146 }
9147 }
9148 return SyncOutcome::Failed {
9149 error_code: code,
9150 message,
9151 };
9152 }
9153 };
9154 #[cfg(feature = "bench-internals")]
9155 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
9156 let response = match decode_message(&response_bytes) {
9157 Ok(message) => message,
9158 Err(error) => {
9159 return SyncOutcome::Failed {
9162 error_code: error.code.as_str().to_owned(),
9163 message: error.detail,
9164 };
9165 }
9166 };
9167 #[cfg(feature = "bench-internals")]
9168 drop(decode_phase);
9169 if response.msg_kind != MsgKind::Response {
9170 return SyncOutcome::Failed {
9171 error_code: "sync.invalid_request".to_owned(),
9172 message: "expected a response message".to_owned(),
9173 };
9174 }
9175 let mut outcome = self.process_response(transport, response, &meta);
9176 if let SyncOutcome::Ok(report) = &mut outcome {
9177 report.deferred_commits = meta.deferred_commits;
9178 }
9179 match &outcome {
9180 SyncOutcome::Ok(_) => self.reset_background_retry(),
9181 SyncOutcome::Failed { error_code, .. } if Self::retryable_failure_code(error_code) => {
9182 self.schedule_background_retry();
9183 }
9184 SyncOutcome::Failed { .. } => {}
9185 SyncOutcome::RealtimeUnavailable { .. } => {}
9186 }
9187 if meta.deferred_commits > 0 {
9188 self.set_sync_needed(true, true);
9191 }
9192 self.previous_version_lifetime_check();
9195 outcome
9196 }
9197
9198 pub fn sync_until_idle(
9199 &mut self,
9200 transport: &mut dyn Transport,
9201 max_rounds: Option<u32>,
9202 ) -> SyncOutcome {
9203 let rounds = max_rounds.unwrap_or(20).max(1);
9204 let mut aggregate = SyncReport::default();
9205 let mut spent = 0;
9206 while spent < rounds {
9207 spent += 1;
9208 let before = self
9209 .subs
9210 .iter()
9211 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9212 .collect::<Vec<_>>();
9213 let outcome = self.sync(transport);
9214 match outcome {
9215 SyncOutcome::Failed {
9216 error_code,
9217 message,
9218 } => {
9219 return SyncOutcome::Failed {
9220 error_code,
9221 message,
9222 };
9223 }
9224 SyncOutcome::RealtimeUnavailable { .. } => return outcome,
9225 SyncOutcome::Ok(report) => {
9226 if max_rounds.is_none()
9227 && report.segment_rows_applied > 0
9228 && !report.bootstrapping.is_empty()
9229 && before
9230 != self
9231 .subs
9232 .iter()
9233 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9234 .collect::<Vec<_>>()
9235 {
9236 spent = 0;
9237 }
9238 aggregate.pushed += report.pushed;
9239 aggregate.applied.extend(report.applied.iter().cloned());
9240 aggregate.rejected.extend(report.rejected.iter().cloned());
9241 aggregate.retryable.extend(report.retryable.iter().cloned());
9242 aggregate.conflicts += report.conflicts;
9243 aggregate.commits_applied += report.commits_applied;
9244 aggregate.segment_rows_applied += report.segment_rows_applied;
9245 aggregate.bootstrapping = report.bootstrapping.clone();
9246 aggregate.resets.extend(report.resets.iter().cloned());
9247 aggregate.revoked.extend(report.revoked.iter().cloned());
9248 aggregate.failed.extend(report.failed.iter().cloned());
9249 aggregate.deferred_commits = report.deferred_commits;
9250 if report.schema_floor.is_some() {
9251 aggregate.schema_floor = report.schema_floor.clone();
9252 }
9253 let more = !report.bootstrapping.is_empty()
9259 || report.commits_applied > 0
9260 || report.segment_rows_applied > 0
9261 || !report.resets.is_empty()
9262 || self.sync_needed;
9263 if !more {
9264 return SyncOutcome::Ok(aggregate);
9265 }
9266 }
9267 }
9268 }
9269 SyncOutcome::Failed {
9270 error_code: "sync.invalid_request".into(),
9271 message: "sync did not reach idle within the round budget".into(),
9272 }
9273 }
9274
9275 fn process_response(
9276 &mut self,
9277 transport: &mut dyn Transport,
9278 response: Message,
9279 meta: &RequestMeta,
9280 ) -> SyncOutcome {
9281 #[cfg(feature = "bench-internals")]
9282 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
9283 let mut report = SyncReport {
9284 pushed: meta.pushed_ids.len() as u32,
9285 ..SyncReport::default()
9286 };
9287 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
9288 HashMap::new();
9289 let pushed_ids = meta
9290 .pushed_ids
9291 .iter()
9292 .map(String::as_str)
9293 .collect::<HashSet<_>>();
9294 let mut last_final_push_result_id: Option<String> = None;
9295 for frame in &response.frames {
9296 match frame {
9297 Frame::PushResultDetails {
9298 client_commit_id,
9299 entries,
9300 } => {
9301 let details = rejection_details_by_commit
9302 .entry(client_commit_id.clone())
9303 .or_default();
9304 for entry in entries {
9305 let parsed = match RejectionDetails::parse(&entry.details.0) {
9306 Ok(value) => value,
9307 Err(message) => {
9308 return SyncOutcome::Failed {
9309 error_code: "sync.invalid_request".to_owned(),
9310 message,
9311 };
9312 }
9313 };
9314 details.insert(entry.op_index, parsed);
9315 }
9316 }
9317 Frame::PushResult {
9318 client_commit_id,
9319 status,
9320 results,
9321 ..
9322 } if pushed_ids.contains(client_commit_id.as_str())
9323 && Self::push_result_is_final(*status, results) =>
9324 {
9325 last_final_push_result_id = Some(client_commit_id.clone());
9326 }
9327 _ => {}
9328 }
9329 }
9330 let mut frames = response.frames.into_iter().peekable();
9331 if response.wire_version < 2 {
9332 return SyncOutcome::Failed {
9333 error_code: "client.invalid_host_response".to_owned(),
9334 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
9335 };
9336 }
9337 match frames.next() {
9338 Some(Frame::RespHeader {
9339 required_schema_version,
9340 latest_schema_version,
9341 log_epoch,
9342 reset_required,
9343 }) => {
9344 let Some(log_epoch) = log_epoch else {
9345 return SyncOutcome::Failed {
9346 error_code: "client.invalid_host_response".to_owned(),
9347 message: "response header omits logEpoch".to_owned(),
9348 };
9349 };
9350 let Some(reset_required) = reset_required else {
9351 return SyncOutcome::Failed {
9352 error_code: "client.invalid_host_response".to_owned(),
9353 message: "response header omits resetRequired".to_owned(),
9354 };
9355 };
9356 if let Some(required) = required_schema_version {
9357 let floor = SchemaFloor {
9360 required_schema_version: Some(required),
9361 latest_schema_version,
9362 };
9363 self.set_schema_floor(Some(floor.clone()));
9364 report.schema_floor = Some(floor);
9365 return SyncOutcome::Ok(report);
9366 }
9367 if reset_required {
9368 if frames.next().is_some() {
9369 return SyncOutcome::Failed {
9370 error_code: "client.invalid_host_response".to_owned(),
9371 message: "log-epoch reset response contains body frames".to_owned(),
9372 };
9373 }
9374 match self.run_log_epoch_reset(&log_epoch) {
9375 Ok(resets) => {
9376 report.resets = resets;
9377 return SyncOutcome::Ok(report);
9378 }
9379 Err(message) => {
9380 return SyncOutcome::Failed {
9381 error_code: "sync.local_corrupt".to_owned(),
9382 message,
9383 };
9384 }
9385 }
9386 }
9387 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
9388 return SyncOutcome::Failed {
9389 error_code: "client.invalid_host_response".to_owned(),
9390 message: "server changed logEpoch without requiring a reset".to_owned(),
9391 };
9392 }
9393 }
9394 _ => {
9395 return SyncOutcome::Failed {
9396 error_code: "sync.invalid_request".to_owned(),
9397 message: "response does not start with RESP_HEADER".to_owned(),
9398 };
9399 }
9400 }
9401
9402 let mut failure: Option<(String, String)> = None;
9403 while let Some(frame) = frames.next() {
9404 match frame {
9405 frame @ Frame::PushResult { .. } => {
9406 let successful = matches!(
9407 &frame,
9408 Frame::PushResult {
9409 status: PushStatus::Applied | PushStatus::Cached,
9410 ..
9411 }
9412 );
9413 let mut results = vec![frame];
9414 if successful {
9415 while matches!(
9416 frames.peek(),
9417 Some(Frame::PushResult {
9418 status: PushStatus::Applied | PushStatus::Cached,
9419 ..
9420 })
9421 ) {
9422 if let Some(next) = frames.next() {
9423 results.push(next);
9424 }
9425 }
9426 }
9427 if self
9428 .handle_push_results(
9429 &results,
9430 &pushed_ids,
9431 &rejection_details_by_commit,
9432 &mut report,
9433 last_final_push_result_id.as_deref(),
9434 )
9435 .is_err()
9436 {
9437 failure = Some((
9438 "client.outcome_persistence_failed".to_owned(),
9439 "local commit outcome could not be persisted".to_owned(),
9440 ));
9441 break;
9442 }
9443 }
9444 Frame::PushResultDetails { .. } => {}
9445 Frame::SubStart {
9446 id,
9447 status,
9448 reason_code,
9449 effective_scopes,
9450 bootstrap: _,
9451 } => {
9452 let mut body = Vec::new();
9453 let mut sub_end: Option<(i64, Option<String>)> = None;
9454 for inner in frames.by_ref() {
9455 match inner {
9456 Frame::SubEnd {
9457 next_cursor,
9458 bootstrap_state,
9459 } => {
9460 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
9461 break;
9462 }
9463 Frame::Unknown { .. } => {}
9464 other => body.push(other),
9465 }
9466 }
9467 if let Err(SectionError::Abort(code, message)) = self.process_section(
9468 transport,
9469 &id,
9470 status,
9471 &reason_code,
9472 effective_scopes,
9473 body,
9474 sub_end,
9475 meta,
9476 &mut report,
9477 ) {
9478 failure = Some((code, message));
9479 break;
9480 }
9481 }
9482 Frame::Lease {
9483 lease_id,
9484 expires_at_ms,
9485 } => {
9486 self.set_lease_state(Some(LeaseState {
9489 lease_id: Some(lease_id),
9490 expires_at_ms: Some(expires_at_ms),
9491 error_code: None,
9492 }));
9493 }
9494 Frame::Error { code, message, .. } => {
9495 failure = Some((code, message));
9498 break;
9499 }
9500 Frame::Unknown { .. } => {}
9501 _ => {
9502 failure = Some((
9503 "sync.invalid_request".to_owned(),
9504 "unexpected frame in response".to_owned(),
9505 ));
9506 break;
9507 }
9508 }
9509 }
9510
9511 if self.overlay_dirty.get() {
9516 self.rebuild_overlay();
9517 }
9518 if let Some((error_code, message)) = failure {
9519 return SyncOutcome::Failed {
9520 error_code,
9521 message,
9522 };
9523 }
9524 if let Err(message) = self.drain_pending_evictions() {
9527 return SyncOutcome::Failed {
9528 error_code: "storage.failed".into(),
9529 message,
9530 };
9531 }
9532 self.ack_after_pull(transport);
9533 if self.upgrading && report.bootstrapping.is_empty() {
9536 self.set_upgrading(false);
9537 }
9538 SyncOutcome::Ok(report)
9539 }
9540
9541 fn handle_push_results(
9544 &mut self,
9545 frames: &[Frame],
9546 pushed_ids: &HashSet<&str>,
9547 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
9548 report: &mut SyncReport,
9549 last_final_push_result_id: Option<&str>,
9550 ) -> Result<(), String> {
9551 self.begin_observation("syncular_push_results")?;
9552 let conflict_count = self.conflicts.len();
9553 let rejection_count = self.rejections.len();
9554 let applied_count = report.applied.len();
9555 let rejected_count = report.rejected.len();
9556 let retryable_count = report.retryable.len();
9557 let was_dirty = self.overlay_dirty.get();
9558 let mut removed = Vec::new();
9559 let mut batch = ChangeAccumulator::default();
9560 let persisted = (|| {
9561 for frame in frames {
9562 let Frame::PushResult {
9563 client_commit_id,
9564 status,
9565 results,
9566 ..
9567 } = frame
9568 else {
9569 return Err("unexpected acknowledgement frame".to_owned());
9570 };
9571 if !pushed_ids.contains(client_commit_id.as_str()) {
9572 continue;
9573 }
9574 let Some(index) = self
9575 .outbox
9576 .iter()
9577 .position(|commit| commit.client_commit_id == *client_commit_id)
9578 else {
9579 continue;
9580 };
9581 let status = *status;
9582 let rejection_details = rejection_details_by_commit.get(client_commit_id);
9583 let operations = self.outbox[index].ops.clone();
9584 match status {
9585 PushStatus::Applied | PushStatus::Cached => {
9586 let journal_results = results
9589 .iter()
9590 .map(|result| {
9591 let op_index = match result {
9592 OpResult::Applied { op_index }
9593 | OpResult::Conflict { op_index, .. }
9594 | OpResult::Error { op_index, .. } => *op_index,
9595 };
9596 CommitOperationOutcome::Applied { op_index }
9597 })
9598 .collect::<Vec<_>>();
9599 let outcome_status = if status == PushStatus::Applied {
9600 CommitOutcomeStatus::Applied
9601 } else {
9602 CommitOutcomeStatus::Cached
9603 };
9604 let persisted = self
9605 .persist_commit_outcome(
9606 client_commit_id,
9607 outcome_status,
9608 &journal_results,
9609 None,
9610 )
9611 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9612 persisted?;
9613 self.overlay_dirty.set(true);
9614 batch.status = true;
9615 batch.outcomes = true;
9616 }
9617 PushStatus::Rejected => {
9618 if results.iter().any(|result| {
9619 matches!(
9620 result,
9621 OpResult::Error {
9622 code,
9623 retryable: true,
9624 ..
9625 } if code == "sync.idempotency_cache_miss"
9626 )
9627 }) {
9628 report.retryable.push(client_commit_id.to_owned());
9631 continue;
9632 }
9633
9634 let mut journal_results = Vec::with_capacity(results.len());
9635 let mut conflicts = Vec::new();
9636 let mut rejections = Vec::new();
9637 for result in results {
9638 match result {
9639 OpResult::Applied { op_index } => {
9640 journal_results.push(CommitOperationOutcome::Applied {
9641 op_index: *op_index,
9642 });
9643 }
9644 OpResult::Conflict {
9645 op_index,
9646 code,
9647 message,
9648 server_version,
9649 server_row,
9650 conflict_columns: conflict_columns_bitmap,
9651 } => {
9652 let operation = operations
9653 .get(*op_index as usize)
9654 .map(CommitOperation::from);
9655 let (table, row_id) = operation
9656 .as_ref()
9657 .map(|op| (op.table.clone(), op.row_id.clone()))
9658 .unwrap_or_default();
9659 let server_row_json = self
9660 .schema
9661 .table(&table)
9662 .and_then(|t| {
9663 decode_row_bytes(t, server_row, &self.encryption)
9664 .ok()
9665 .map(|row| (t, row))
9666 })
9667 .map(|(t, row)| {
9668 let mut map = Map::new();
9669 for (i, column) in t.columns.iter().enumerate() {
9670 map.insert(
9671 column.name.clone(),
9672 column_value_to_json(
9673 row.get(i).unwrap_or(&None),
9674 ),
9675 );
9676 }
9677 map
9678 })
9679 .unwrap_or_default();
9680 let conflict_columns = self
9683 .schema
9684 .table(&table)
9685 .map(|t| {
9686 t.columns
9687 .iter()
9688 .enumerate()
9689 .filter(|(index, _)| {
9690 conflict_columns_bitmap
9691 .get(index / 8)
9692 .is_some_and(|byte| {
9693 byte & (1 << (index % 8)) != 0
9694 })
9695 })
9696 .map(|(_, column)| column.name.clone())
9697 .collect::<Vec<_>>()
9698 })
9699 .unwrap_or_default();
9700 let conflict = ConflictRecord {
9701 client_commit_id: client_commit_id.to_owned(),
9702 op_index: *op_index,
9703 table,
9704 row_id,
9705 code: code.clone(),
9706 message: message.clone(),
9707 server_version: *server_version,
9708 server_row: server_row_json,
9709 conflict_columns,
9710 operation,
9711 };
9712 journal_results.push(CommitOperationOutcome::Conflict {
9713 conflict: conflict.clone(),
9714 });
9715 conflicts.push(conflict);
9716 }
9717 OpResult::Error {
9718 op_index,
9719 code,
9720 message,
9721 retryable,
9722 } => {
9723 let rejection = RejectionRecord {
9724 client_commit_id: client_commit_id.to_owned(),
9725 op_index: *op_index,
9726 code: code.clone(),
9727 message: message.clone(),
9728 retryable: *retryable,
9729 details: rejection_details
9730 .and_then(|details| details.get(op_index))
9731 .cloned(),
9732 operation: operations
9733 .get(*op_index as usize)
9734 .map(CommitOperation::from),
9735 };
9736 journal_results.push(CommitOperationOutcome::Error {
9737 rejection: rejection.clone(),
9738 });
9739 rejections.push(rejection);
9740 }
9741 }
9742 }
9743 let outcome_status = if conflicts.is_empty() {
9744 CommitOutcomeStatus::Rejected
9745 } else {
9746 CommitOutcomeStatus::Conflict
9747 };
9748 let persisted = self
9749 .persist_commit_outcome(
9750 client_commit_id,
9751 outcome_status,
9752 &journal_results,
9753 Some(&operations),
9754 )
9755 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9756 persisted?;
9757 batch.conflicts = !conflicts.is_empty();
9758 batch.rejections = !rejections.is_empty();
9759 batch.status = true;
9760 batch.outcomes = true;
9761 self.conflicts.extend(conflicts);
9762 self.rejections.extend(rejections);
9763 self.overlay_dirty.set(true);
9764 }
9765 }
9766 removed.push((index, self.outbox.remove(index)));
9767 if batch.status {
9768 for operation in &operations {
9769 if !self.record_row_scopes(
9770 &mut batch,
9771 &operation.table,
9772 &operation.row_id,
9773 false,
9774 ) {
9775 batch.table(&operation.table);
9776 }
9777 }
9778 }
9779 match status {
9780 PushStatus::Applied | PushStatus::Cached => {
9781 report.applied.push(client_commit_id.to_owned())
9782 }
9783 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
9784 }
9785 }
9786 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)) {
9787 self.prune_commit_outcomes()?;
9788 }
9789 self.finish_observation("syncular_push_results", batch)
9790 })();
9791 if let Err(error) = persisted {
9792 self.rollback_observation("syncular_push_results");
9793 for (index, commit) in removed.into_iter().rev() {
9794 self.outbox.insert(index, commit);
9795 }
9796 self.conflicts.truncate(conflict_count);
9797 self.rejections.truncate(rejection_count);
9798 self.overlay_dirty.set(was_dirty);
9799 report.applied.truncate(applied_count);
9800 report.rejected.truncate(rejected_count);
9801 report.retryable.truncate(retryable_count);
9802 return Err(error);
9803 }
9804 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
9805 Ok(())
9806 }
9807
9808 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
9809 status != PushStatus::Rejected
9810 || !results.iter().any(|result| {
9811 matches!(
9812 result,
9813 OpResult::Error {
9814 code,
9815 retryable: true,
9816 ..
9817 } if code == "sync.idempotency_cache_miss"
9818 )
9819 })
9820 }
9821
9822 #[allow(clippy::too_many_arguments)]
9825 fn process_section(
9826 &mut self,
9827 transport: &mut dyn Transport,
9828 id: &str,
9829 status: SubStatus,
9830 reason_code: &str,
9831 effective_scopes: Vec<(String, Vec<String>)>,
9832 body: Vec<Frame>,
9833 sub_end: Option<(i64, Option<String>)>,
9834 meta: &RequestMeta,
9835 report: &mut SyncReport,
9836 ) -> Result<(), SectionError> {
9837 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
9838 return Ok(()); };
9840 if status != SubStatus::Active && sub_end.is_none() {
9841 return Err(SectionError::Abort(
9842 "sync.invalid_request".into(),
9843 "subscription section without SUB_END".into(),
9844 ));
9845 }
9846 match status {
9847 SubStatus::Revoked => {
9848 self.begin_observation("syncular_revocation")
9849 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9850 let previous = self.subs[sub_index].clone();
9851 let was_dirty = self.overlay_dirty.get();
9852 let previous_outbox = self.outbox.clone();
9853 let rejection_count = self.rejections.len();
9854 let revoked_count = report.revoked.len();
9855 let failed_count = report.failed.len();
9856 let result =
9857 (|| {
9858 let mut batch = ChangeAccumulator::default();
9859 let registered = self.window_unit_by_sub_id(id);
9860 let (table, effective) = {
9862 let sub = &self.subs[sub_index];
9863 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9864 };
9865 let purged = self.purge_scope_rows(&table, &effective);
9866 match purged {
9867 Ok(()) => {
9868 self.record_scope_map(&mut batch, &table, &effective);
9869 let sub = &mut self.subs[sub_index];
9870 sub.state = SubState::Revoked;
9871 sub.reason_code = Some(if reason_code.is_empty() {
9872 "sync.scope_revoked".to_owned()
9873 } else {
9874 reason_code.to_owned()
9875 });
9876 report.revoked.push(id.to_owned());
9877 let doomed_effective = effective;
9878 let sub_table = table;
9879 self.persist_sub(&self.subs[sub_index].clone()).map_err(
9880 |message| SectionError::Abort("storage.failed".into(), message),
9881 )?;
9882 let dropped = self
9883 .drop_doomed_outbox(&sub_table, &doomed_effective)
9884 .map_err(|message| {
9885 SectionError::Abort("storage.failed".to_owned(), message)
9886 })?;
9887 if dropped {
9888 batch.status = true;
9889 batch.rejections = true;
9890 batch.outcomes = true;
9891 }
9892 self.delete_unreferenced_cached_blobs();
9895 }
9896 Err(()) => {
9897 let sub = &mut self.subs[sub_index];
9900 sub.state = SubState::Failed;
9901 sub.reason_code = Some("sync.scope_revoked".to_owned());
9902 report.failed.push(id.to_owned());
9903 self.persist_sub(&self.subs[sub_index].clone()).map_err(
9904 |message| SectionError::Abort("storage.failed".into(), message),
9905 )?;
9906 }
9907 }
9908 if let Some((base_key, unit)) = registered {
9909 batch.window(&base_key, &self.subs[sub_index].table, &unit);
9910 }
9911 self.rebuild_overlay_if_dirty();
9912 self.finish_observation("syncular_revocation", batch)
9913 .map_err(|message| {
9914 SectionError::Abort("storage.failed".to_owned(), message)
9915 })?;
9916 Ok(())
9917 })();
9918 if result.is_err() {
9919 self.rollback_observation("syncular_revocation");
9920 self.subs[sub_index] = previous;
9921 self.overlay_dirty.set(was_dirty);
9922 self.outbox = previous_outbox;
9923 self.rejections.truncate(rejection_count);
9924 report.revoked.truncate(revoked_count);
9925 report.failed.truncate(failed_count);
9926 }
9927 result
9928 }
9929 SubStatus::Reset => {
9930 self.begin_observation("syncular_reset")
9931 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9932 let previous = self.subs[sub_index].clone();
9933 let was_dirty = self.overlay_dirty.get();
9934 let reset_count = report.resets.len();
9935 let result = (|| {
9936 let mut batch = ChangeAccumulator::default();
9937 let registered = self.window_unit_by_sub_id(id);
9938 let sub = &mut self.subs[sub_index];
9941 sub.cursor = -1;
9942 sub.bootstrap_state = None;
9943 report.resets.push(id.to_owned());
9944 self.persist_sub(&self.subs[sub_index].clone())
9945 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9946 if let Some((base_key, unit)) = registered {
9947 batch.window(&base_key, &self.subs[sub_index].table, &unit);
9948 }
9949 self.finish_observation("syncular_reset", batch)
9950 .map_err(|message| {
9951 SectionError::Abort("storage.failed".to_owned(), message)
9952 })?;
9953 Ok(())
9954 })();
9955 if result.is_err() {
9956 self.rollback_observation("syncular_reset");
9957 self.subs[sub_index] = previous;
9958 self.overlay_dirty.set(was_dirty);
9959 report.resets.truncate(reset_count);
9960 }
9961 result
9962 }
9963 SubStatus::Active => {
9964 let fresh = meta
9965 .fresh
9966 .iter()
9967 .find(|(fid, _)| fid == id)
9968 .map(|(_, fresh)| *fresh)
9969 .unwrap_or(false);
9970 let previous = self.subs[sub_index].clone();
9971 self.subs[sub_index].effective = Some(effective_scopes);
9972 let outcome = self
9973 .apply_section_body(transport, sub_index, body, fresh, meta, report)
9974 .and_then(|()| {
9975 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
9976 SectionError::Abort(
9977 "sync.invalid_request".into(),
9978 "subscription section without SUB_END".into(),
9979 )
9980 })?;
9981 self.apply_sub_end(sub_index, cursor, bootstrap_state)
9982 .map_err(|message| {
9983 SectionError::Abort("storage.failed".into(), message)
9984 })
9985 });
9986 match outcome {
9987 Ok(()) => {
9988 if self.subs[sub_index].bootstrap_state.is_some() {
9989 report.bootstrapping.push(id.to_owned());
9990 }
9991 Ok(())
9992 }
9993 Err(SectionError::FailClosed) => {
9994 self.subs[sub_index] = previous.clone();
9995 self.begin_observation("syncular_section_failure")
9996 .map_err(|message| {
9997 SectionError::Abort("storage.failed".into(), message)
9998 })?;
9999 let persisted = (|| {
10000 let mut batch = ChangeAccumulator::default();
10001 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
10002 batch.window(&base_key, &self.subs[sub_index].table, &unit);
10003 }
10004 self.subs[sub_index].state = SubState::Failed;
10005 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
10006 self.persist_sub(&self.subs[sub_index])?;
10007 self.finish_observation("syncular_section_failure", batch)
10008 })();
10009 if let Err(message) = persisted {
10010 self.rollback_observation("syncular_section_failure");
10011 self.subs[sub_index] = previous;
10012 return Err(SectionError::Abort("storage.failed".into(), message));
10013 }
10014 report.failed.push(id.to_owned());
10015 Ok(())
10016 }
10017 Err(error) => {
10018 self.subs[sub_index] = previous;
10021 Err(error)
10022 }
10023 }
10024 }
10025 }
10026 }
10027
10028 fn apply_sub_end(
10029 &mut self,
10030 sub_index: usize,
10031 cursor: i64,
10032 bootstrap_state: Option<String>,
10033 ) -> Result<(), String> {
10034 #[cfg(feature = "bench-internals")]
10035 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
10036 let previous = self.subs[sub_index].clone();
10037 self.begin_observation("syncular_sub_end")?;
10038 let persisted = (|| {
10039 let mut batch = ChangeAccumulator::default();
10040 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
10041 && cursor >= 0
10042 && bootstrap_state.is_none();
10043 if completed {
10044 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
10045 batch.window(&base_key, &previous.table, &unit);
10046 }
10047 }
10048 let sub = &mut self.subs[sub_index];
10049 sub.cursor = cursor;
10050 sub.bootstrap_state = bootstrap_state;
10051 sub.synced_once = true;
10052 self.persist_sub(&self.subs[sub_index])?;
10053 self.finish_observation("syncular_sub_end", batch)
10054 })();
10055 if persisted.is_err() {
10056 self.rollback_observation("syncular_sub_end");
10057 self.subs[sub_index] = previous;
10058 }
10059 persisted
10060 }
10061
10062 fn apply_section_body(
10063 &mut self,
10064 transport: &mut dyn Transport,
10065 sub_index: usize,
10066 body: Vec<Frame>,
10067 fresh: bool,
10068 meta: &RequestMeta,
10069 report: &mut SyncReport,
10070 ) -> Result<(), SectionError> {
10071 let mut saw_segment = false;
10072 for frame in body {
10073 match frame {
10074 Frame::Commit {
10075 tables, changes, ..
10076 } => {
10077 self.apply_commit_frame(&tables, &changes)
10078 .map_err(|(c, m)| SectionError::Abort(c, m))?;
10079 report.commits_applied += 1;
10080 }
10081 Frame::SegmentInline { payload } => {
10082 let segment = decode_rows_segment(&payload)
10083 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
10084 self.progress.update(|p| {
10085 p.phase = ProgressPhase::Import;
10086 p.subscription_id = Some(self.subs[sub_index].id.clone());
10087 p.table = Some(self.subs[sub_index].table.clone());
10088 p.segment_id = None;
10089 p.bytes_received = payload.len() as u64;
10090 p.bytes_total = Some(payload.len() as u64);
10091 p.rows_processed = 0;
10092 p.rows_total =
10093 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
10094 });
10095 let first = !saw_segment;
10096 saw_segment = true;
10097 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10098 report.segment_rows_applied += applied;
10099 }
10100 Frame::SegmentRef {
10101 segment_id,
10102 byte_length,
10103 media_type,
10104 table,
10105 row_count,
10106 as_of_commit_seq,
10107 scope_digest,
10108 row_cursor,
10109 next_row_cursor,
10110 url,
10111 url_expires_at_ms,
10112 ..
10113 } => {
10114 let advertised = match media_type {
10117 MediaType::Rows => {
10118 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
10119 || meta.accept & ACCEPT_INLINE_ROWS != 0
10120 }
10121 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
10122 };
10123 if !advertised {
10124 return Err(SectionError::Abort(
10125 "sync.invalid_request".to_owned(),
10126 format!(
10127 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
10128 media_type.name()
10129 ),
10130 ));
10131 }
10132 self.progress.update(|p| {
10133 p.phase = ProgressPhase::Download;
10134 p.subscription_id = Some(self.subs[sub_index].id.clone());
10135 p.table = Some(table.clone());
10136 p.segment_id = Some(segment_id.clone());
10137 p.bytes_received = 0;
10138 p.bytes_total = u64::try_from(byte_length).ok();
10139 p.rows_processed = 0;
10140 p.rows_total = u64::try_from(row_count).ok();
10141 });
10142 let observer = self.progress.clone();
10143 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
10144 let bytes = if let Some(url) = url {
10145 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
10150 return Err(SectionError::Abort(
10151 "sync.invalid_request".to_owned(),
10152 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
10153 .to_owned(),
10154 ));
10155 }
10156 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
10158 return Err(SectionError::Abort(
10159 "sync.segment_expired".to_owned(),
10160 format!(
10161 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
10162 ),
10163 ));
10164 }
10165 transport
10166 .fetch_url(&url, &mut on_progress)
10167 .map_err(|e| SectionError::Abort(e.code, e.message))?
10168 } else {
10169 let requested_scopes_json =
10170 canonical_scope_json(&self.subs[sub_index].requested);
10171 transport
10172 .download_segment(
10173 &SegmentRequest {
10174 segment_id: segment_id.clone(),
10175 table,
10176 requested_scopes_json,
10177 },
10178 &mut on_progress,
10179 )
10180 .map_err(|e| SectionError::Abort(e.code, e.message))?
10181 };
10182 on_progress(bytes.len() as u64);
10184 let digest = Sha256::digest(&bytes);
10185 let expected = segment_id
10186 .strip_prefix("sha256:")
10187 .unwrap_or(segment_id.as_str());
10188 if bytes_to_hex(&digest) != expected {
10189 return Err(SectionError::Abort(
10190 "sync.invalid_request".to_owned(),
10191 "segment bytes do not match the content address (§5.1)".to_owned(),
10192 ));
10193 }
10194 self.progress.update(|p| p.phase = ProgressPhase::Import);
10195 if media_type == MediaType::Sqlite {
10196 if row_cursor.is_some() || next_row_cursor.is_some() {
10199 return Err(SectionError::Abort(
10200 "sync.invalid_request".to_owned(),
10201 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
10202 .to_owned(),
10203 ));
10204 }
10205 let first = !saw_segment;
10206 saw_segment = true;
10207 let applied = self.apply_sqlite_segment(
10208 sub_index,
10209 &bytes,
10210 fresh && first,
10211 row_count,
10212 as_of_commit_seq,
10213 &scope_digest,
10214 )?;
10215 report.segment_rows_applied += applied;
10216 } else {
10217 let segment = decode_rows_segment(&bytes).map_err(|e| {
10218 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
10219 })?;
10220 let first = row_cursor.is_none();
10221 saw_segment = true;
10222 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10223 report.segment_rows_applied += applied;
10224 }
10225 }
10226 Frame::Unknown { .. } => {}
10227 Frame::Error { code, message, .. } => {
10228 return Err(SectionError::Abort(code, message))
10229 }
10230 _ => {
10231 return Err(SectionError::Abort(
10232 "sync.invalid_request".to_owned(),
10233 "unexpected frame inside a subscription section".to_owned(),
10234 ));
10235 }
10236 }
10237 }
10238 Ok(())
10239 }
10240
10241 fn apply_commit_frame(
10243 &mut self,
10244 tables: &[String],
10245 changes: &[ssp2::model::Change],
10246 ) -> Result<(), (String, String)> {
10247 #[cfg(feature = "bench-internals")]
10248 let _phase = self.benchmark_phases.start(Phase::CommitApply);
10249 let was_dirty = self.overlay_dirty.get();
10250 self.begin_observation("syncular_commit")
10251 .map_err(|message| ("storage.failed".into(), message))?;
10252 let applied = (|| {
10253 let mut batch = ChangeAccumulator::default();
10254 self.record_commit_changes(&mut batch, tables, changes);
10255 self.apply_commit_changes(tables, changes)?;
10256 self.rebuild_overlay_if_dirty();
10257 self.finish_observation("syncular_commit", batch)
10258 .map_err(|message| ("storage.failed".into(), message))
10259 })();
10260 if applied.is_err() {
10261 self.rollback_observation("syncular_commit");
10262 self.overlay_dirty.set(was_dirty);
10263 }
10264 applied
10265 }
10266
10267 fn apply_commit_changes(
10268 &mut self,
10269 tables: &[String],
10270 changes: &[ssp2::model::Change],
10271 ) -> Result<(), (String, String)> {
10272 let mirror_visible = !self.overlay_dirty.get()
10277 && (self.outbox.is_empty()
10278 || changes.iter().all(|change| {
10279 tables
10280 .get(change.table_index as usize)
10281 .and_then(|name| self.schema.table(name))
10282 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
10283 }));
10284 for change in changes {
10285 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
10286 (
10287 "sync.invalid_request".to_owned(),
10288 "change tableIndex out of range".to_owned(),
10289 )
10290 })?;
10291 let table = self.schema.table(table_name).ok_or_else(|| {
10292 (
10293 "sync.schema_mismatch".to_owned(),
10294 format!("change targets unknown table {table_name:?}"),
10295 )
10296 })?;
10297 match change.op {
10298 Op::Upsert => {
10299 let payload = change.row.as_ref().ok_or_else(|| {
10300 (
10301 "sync.invalid_request".to_owned(),
10302 "upsert change without row payload".to_owned(),
10303 )
10304 })?;
10305 let row = {
10307 #[cfg(feature = "bench-internals")]
10308 let _phase = self.benchmark_phases.start(Phase::RowDecode);
10309 decode_row_bytes(table, payload, &self.encryption)
10310 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
10311 };
10312 let version = change.row_version.unwrap_or(0);
10313 let table_name = table.name.clone();
10314 self.write_base_row(&table_name, &row, version)
10315 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10316 if mirror_visible {
10317 self.write_row(&visible_table(&table_name), &table_name, &row, version)
10318 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10319 }
10320 }
10321 Op::Delete => {
10322 self.delete_base_row(table_name, &change.row_id)
10323 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10324 if mirror_visible {
10325 let sql = format!(
10326 "DELETE FROM {} WHERE {}",
10327 visible_table(table_name),
10328 row_id_predicate(table)
10329 );
10330 self.conn
10331 .prepare_cached(&sql)
10332 .and_then(|mut statement| {
10333 statement.execute(rusqlite::params![change.row_id])
10334 })
10335 .map_err(|error| {
10336 ("sync.invalid_request".to_owned(), error.to_string())
10337 })?;
10338 }
10339 }
10340 }
10341 }
10342 if mirror_visible {
10343 if !self.outbox.is_empty() {
10344 let changed_rows: HashSet<(&str, &str)> = changes
10345 .iter()
10346 .map(|change| {
10347 (
10348 tables[change.table_index as usize].as_str(),
10349 change.row_id.as_str(),
10350 )
10351 })
10352 .collect();
10353 self.apply_outbox_ops(
10354 self.outbox
10355 .iter()
10356 .flat_map(|commit| &commit.ops)
10357 .filter(|op| {
10358 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
10359 }),
10360 );
10361 }
10362 self.overlay_dirty.set(false);
10363 }
10364 Ok(())
10365 }
10366
10367 fn apply_segment(
10372 &mut self,
10373 sub_index: usize,
10374 segment: &RowsSegment,
10375 first_fresh_page: bool,
10376 ) -> Result<u32, SectionError> {
10377 let (sub_table, effective) = {
10378 let sub = &self.subs[sub_index];
10379 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10380 };
10381 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10382 SectionError::Abort(
10383 "sync.schema_mismatch".to_owned(),
10384 format!("subscription table {sub_table:?} is not in the client schema"),
10385 )
10386 })?;
10387 let matches = segment.table == table.name
10392 && segment.schema_version == self.schema.version
10393 && segment.columns.len() == table.wire_columns.len()
10394 && segment
10395 .columns
10396 .iter()
10397 .zip(table.wire_columns.iter())
10398 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
10399 if !matches {
10400 return Err(SectionError::Abort(
10401 "sync.schema_mismatch".to_owned(),
10402 "segment column table does not match the generated schema (§5.2)".to_owned(),
10403 ));
10404 }
10405 let mut applied = 0u32;
10406 let empty = Vec::new();
10407 let blocks = if segment.blocks.is_empty() {
10408 std::slice::from_ref(&empty)
10409 } else {
10410 &segment.blocks
10411 };
10412 for (index, block) in blocks.iter().enumerate() {
10413 let clear = first_fresh_page && index == 0;
10414 if !clear && block.is_empty() {
10415 continue;
10416 }
10417 let was_dirty = self.overlay_dirty.get();
10418 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10419 && self
10420 .outbox
10421 .iter()
10422 .flat_map(|commit| &commit.ops)
10423 .any(|op| op.table == table.name);
10424 let mirror_visible = !reconcile_pending;
10425 self.begin_observation("syncular_segment_block")
10426 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10427 let outcome = (|| {
10428 self.rebuild_overlay_if_dirty();
10429 let mut changed_keys = Vec::new();
10430 let mut batch = ChangeAccumulator::default();
10431 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
10432 batch.table(&table.name);
10433 }
10434 if clear {
10435 self.purge_scope_rows(&table.name, &effective)
10436 .map_err(|()| SectionError::FailClosed)?;
10437 }
10438 for (row_index, row) in block.iter().enumerate() {
10439 let decrypted;
10440 let values = if table.has_encrypted_columns() {
10441 let mut values = row.values.clone();
10442 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
10443 .map_err(|message| {
10444 SectionError::Abort("client.decrypt_failed".into(), message)
10445 })?;
10446 decrypted = values;
10447 &decrypted
10448 } else {
10449 &row.values
10450 };
10451 self.write_base_row(&table.name, values, row.server_version)
10452 .map_err(|message| {
10453 SectionError::Abort("sync.invalid_request".into(), message)
10454 })?;
10455 if reconcile_pending {
10456 changed_keys.push(
10457 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
10458 |message| SectionError::Abort("storage.failed".into(), message),
10459 )?,
10460 );
10461 }
10462 if mirror_visible {
10463 self.write_row(
10464 &visible_table(&table.name),
10465 &table.name,
10466 values,
10467 row.server_version,
10468 )
10469 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10470 }
10471 let processed = u64::from(applied) + row_index as u64 + 1;
10472 if processed.is_multiple_of(1024) {
10473 self.progress.update(|p| p.rows_processed = processed);
10474 }
10475 }
10476 if reconcile_pending {
10477 self.reconcile_imported_rows(&table, &mut changed_keys)
10478 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10479 }
10480 self.apply_outbox_ops(
10481 self.outbox
10482 .iter()
10483 .flat_map(|commit| &commit.ops)
10484 .filter(|op| op.table == table.name),
10485 );
10486 self.overlay_dirty.set(false);
10487 self.finish_observation("syncular_segment_block", batch)
10488 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
10489 })();
10490 if let Err(error) = outcome {
10491 self.rollback_observation("syncular_segment_block");
10492 self.overlay_dirty.set(was_dirty);
10493 return Err(error);
10494 }
10495 applied += block.len() as u32;
10496 self.progress
10497 .update(|p| p.rows_processed = u64::from(applied));
10498 std::thread::yield_now();
10499 }
10500 self.progress
10501 .update(|p| p.rows_processed = u64::from(applied));
10502 Ok(applied)
10503 }
10504
10505 fn apply_sqlite_segment(
10513 &mut self,
10514 sub_index: usize,
10515 bytes: &[u8],
10516 first_fresh_page: bool,
10517 row_count: i64,
10518 as_of_commit_seq: i64,
10519 scope_digest: &str,
10520 ) -> Result<u32, SectionError> {
10521 let invalid = |detail: &str| {
10522 SectionError::Abort(
10523 "sync.invalid_request".to_owned(),
10524 format!("sqlite segment rejected: {detail} (§5.3)"),
10525 )
10526 };
10527 let (sub_table, effective) = {
10528 let sub = &self.subs[sub_index];
10529 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10530 };
10531 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10532 SectionError::Abort(
10533 "sync.schema_mismatch".to_owned(),
10534 format!("subscription table {sub_table:?} is not in the client schema"),
10535 )
10536 })?;
10537
10538 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
10539 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
10540 let img = match rusqlite::Connection::open_with_flags(
10541 &path,
10542 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
10543 ) {
10544 Ok(conn) => conn,
10545 Err(_) => {
10546 let _ = std::fs::remove_file(&path);
10547 return Err(invalid("bytes do not open as a SQLite database"));
10548 }
10549 };
10550 let outcome = self.apply_sqlite_image(
10551 &img,
10552 &table,
10553 first_fresh_page,
10554 &effective,
10555 row_count,
10556 as_of_commit_seq,
10557 scope_digest,
10558 );
10559 drop(img);
10560 let _ = std::fs::remove_file(&path);
10561 outcome
10562 }
10563
10564 #[allow(clippy::too_many_arguments)]
10565 fn apply_sqlite_image(
10566 &mut self,
10567 img: &rusqlite::Connection,
10568 table: &crate::schema::TableSchema,
10569 first_fresh_page: bool,
10570 effective: &[(String, Vec<String>)],
10571 row_count: i64,
10572 as_of_commit_seq: i64,
10573 scope_digest: &str,
10574 ) -> Result<u32, SectionError> {
10575 let invalid = |detail: String| {
10576 SectionError::Abort(
10577 "sync.invalid_request".to_owned(),
10578 format!("sqlite segment rejected: {detail} (§5.3)"),
10579 )
10580 };
10581
10582 type MetaRow = (i64, String, i64, i64, String, i64, i64);
10585 let meta: MetaRow = img
10586 .query_row(
10587 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
10588 \"scopeDigest\", \"rowCount\",
10589 (SELECT count(*) FROM _syncular_segment)
10590 FROM _syncular_segment",
10591 [],
10592 |row| {
10593 Ok((
10594 row.get(0)?,
10595 row.get(1)?,
10596 row.get(2)?,
10597 row.get(3)?,
10598 row.get(4)?,
10599 row.get(5)?,
10600 row.get(6)?,
10601 ))
10602 },
10603 )
10604 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
10605 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
10606 if meta_count != 1 {
10607 return Err(invalid(format!(
10608 "_syncular_segment must contain exactly one row, found {meta_count}"
10609 )));
10610 }
10611 if format != 1 {
10612 return Err(invalid(format!("format {format}")));
10613 }
10614 if meta_table != table.name {
10615 return Err(invalid(format!("image table {meta_table:?}")));
10616 }
10617 if schema_version != i64::from(self.schema.version) {
10618 return Err(invalid(format!("schemaVersion {schema_version}")));
10619 }
10620 if pin != as_of_commit_seq {
10621 return Err(invalid(format!("asOfCommitSeq {pin}")));
10622 }
10623 if digest != scope_digest {
10624 return Err(invalid("scopeDigest mismatch".to_owned()));
10625 }
10626 if meta_rows != row_count {
10627 return Err(invalid(format!("rowCount {meta_rows}")));
10628 }
10629
10630 let mut names: Vec<String> = Vec::new();
10632 let mut primary_keys: Vec<String> = Vec::new();
10633 {
10634 let mut stmt = img
10635 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
10636 .map_err(|_| invalid("image data table missing".to_owned()))?;
10637 let mut rows = stmt
10638 .query([])
10639 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
10640 while let Some(row) = rows
10641 .next()
10642 .map_err(|_| invalid("image data table unreadable".to_owned()))?
10643 {
10644 if row.get::<_, i64>(5).map_err(|e| invalid(e.to_string()))? > 0 {
10645 primary_keys.push(row.get(1).map_err(|e| invalid(e.to_string()))?);
10646 }
10647 names.push(
10648 row.get::<_, String>(1)
10649 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
10650 );
10651 }
10652 }
10653 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
10654 expected.push("_syncular_version");
10655 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
10656 return Err(SectionError::Abort(
10657 "sync.schema_mismatch".to_owned(),
10658 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
10659 ));
10660 }
10661
10662 if primary_keys != vec![table.primary_key.clone()] {
10663 return Err(invalid(
10664 "image primary key does not match generated schema".into(),
10665 ));
10666 }
10667 let actual_count: i64 = img
10668 .query_row(
10669 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
10670 [],
10671 |row| row.get(0),
10672 )
10673 .map_err(|e| invalid(e.to_string()))?;
10674 if actual_count != row_count {
10675 return Err(invalid("image row count does not match descriptor".into()));
10676 }
10677 let insert = self.insert_row_sql(&base_table(&table.name), table);
10678 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
10679 let column_list = names
10680 .iter()
10681 .map(|n| quote_ident(n))
10682 .collect::<Vec<_>>()
10683 .join(", ");
10684 let mut stmt = img
10685 .prepare(&format!(
10686 "SELECT {column_list} FROM {} ORDER BY {}",
10687 quote_ident(&table.name),
10688 quote_ident(&table.primary_key)
10689 ))
10690 .map_err(|e| invalid(e.to_string()))?;
10691 let mut rows = stmt.query([]).map_err(|e| invalid(e.to_string()))?;
10692 let mut applied = 0u32;
10693 loop {
10694 let clear = applied == 0 && first_fresh_page;
10695 let was_dirty = self.overlay_dirty.get();
10696 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10697 && self
10698 .outbox
10699 .iter()
10700 .flat_map(|commit| &commit.ops)
10701 .any(|op| op.table == table.name);
10702 let mirror_visible = !reconcile_pending;
10703 self.begin_observation("syncular_image_chunk")
10704 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10705 let result = (|| {
10706 self.rebuild_overlay_if_dirty();
10707 let mut changed_keys = Vec::new();
10708 let mut batch = ChangeAccumulator::default();
10709 if clear {
10710 if self.scoped_rows_exist(&table.name, effective) {
10711 batch.table(&table.name);
10712 }
10713 self.purge_scope_rows(&table.name, effective)
10714 .map_err(|()| SectionError::FailClosed)?;
10715 }
10716 let mut processed = 0u32;
10717 {
10718 let mut ins = self
10719 .conn
10720 .prepare_cached(&insert)
10721 .map_err(|e| invalid(e.to_string()))?;
10722 let mut mirror = if mirror_visible {
10723 Some(
10724 self.conn
10725 .prepare_cached(&visible_insert)
10726 .map_err(|e| invalid(e.to_string()))?,
10727 )
10728 } else {
10729 None
10730 };
10731 for _ in 0..1024 {
10732 let Some(row) = rows.next().map_err(|e| invalid(e.to_string()))? else {
10733 break;
10734 };
10735 for (i, column) in table.columns.iter().enumerate() {
10736 let cell = row.get_ref(i).map_err(|e| invalid(e.to_string()))?;
10737 let param = image_cell_param(column, cell).map_err(&invalid)?;
10738 ins.raw_bind_parameter(i + 1, ¶m)
10739 .map_err(|e| invalid(e.to_string()))?;
10740 if let Some(mirror) = &mut mirror {
10741 mirror
10742 .raw_bind_parameter(i + 1, ¶m)
10743 .map_err(|e| invalid(e.to_string()))?;
10744 }
10745 }
10746 let version_index = table.columns.len();
10747 let version: i64 =
10748 row.get(version_index).map_err(|e| invalid(e.to_string()))?;
10749 if version < 1 {
10750 return Err(invalid("image row version must be positive".into()));
10751 }
10752 ins.raw_bind_parameter(version_index + 1, version)
10753 .map_err(|e| invalid(e.to_string()))?;
10754 ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
10755 if let Some(mirror) = &mut mirror {
10756 mirror
10757 .raw_bind_parameter(version_index + 1, version)
10758 .map_err(|e| invalid(e.to_string()))?;
10759 mirror.raw_execute().map_err(|e| invalid(e.to_string()))?;
10760 }
10761 if reconcile_pending {
10762 changed_keys.push(
10763 row.get::<_, SqlValue>(table.pk_index)
10764 .map_err(|error| invalid(error.to_string()))?,
10765 );
10766 }
10767 processed += 1;
10768 }
10769 }
10770 if processed > 0 {
10771 batch.table(&table.name);
10772 self.overlay_dirty.set(true);
10773 }
10774 if reconcile_pending {
10775 self.reconcile_imported_rows(table, &mut changed_keys)
10776 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10777 }
10778 self.apply_outbox_ops(
10779 self.outbox
10780 .iter()
10781 .flat_map(|commit| &commit.ops)
10782 .filter(|op| op.table == table.name),
10783 );
10784 self.overlay_dirty.set(false);
10785 self.finish_observation("syncular_image_chunk", batch)
10786 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10787 Ok(processed)
10788 })();
10789 let processed = match result {
10790 Ok(processed) => processed,
10791 Err(error) => {
10792 self.rollback_observation("syncular_image_chunk");
10793 self.overlay_dirty.set(was_dirty);
10794 return Err(error);
10795 }
10796 };
10797 applied += processed;
10798 self.progress
10799 .update(|p| p.rows_processed = u64::from(applied));
10800 std::thread::yield_now();
10801 if i64::from(applied) == actual_count {
10802 break;
10803 }
10804 }
10805 Ok(applied)
10806 }
10807
10808 fn reconcile_imported_rows(
10812 &self,
10813 table: &TableSchema,
10814 keys: &mut Vec<SqlValue>,
10815 ) -> Result<(), String> {
10816 let visible = visible_table(&table.name);
10817 let base = base_table(&table.name);
10818 let pk = quote_ident(&table.primary_key);
10819 let predicate = row_id_predicate(table);
10820 let mut lookup = self.conn.prepare_cached(&format!(
10821 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
10822 )).map_err(|error| error.to_string())?;
10823 let mut deleted = HashSet::new();
10824 for op in self
10825 .outbox
10826 .iter()
10827 .flat_map(|commit| &commit.ops)
10828 .filter(|op| op.table == table.name)
10829 {
10830 if op.upsert {
10831 if let Some(values) = &op.values {
10832 let key = json_to_column_value(
10833 &table.columns[table.pk_index],
10834 values.get(&table.primary_key),
10835 )?;
10836 keys.push(owned_sql_value(RowParam::Cell(&key))?);
10837 }
10838 } else if deleted.insert(&op.row_id) {
10839 keys.extend(
10840 lookup
10841 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
10842 .map_err(|error| error.to_string())?
10843 .collect::<Result<Vec<_>, _>>()
10844 .map_err(|error| error.to_string())?,
10845 );
10846 }
10847 }
10848 for chunk in keys.chunks(400) {
10851 let holes = vec!["?"; chunk.len()].join(",");
10852 self.conn
10853 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
10854 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10855 .map_err(|error| error.to_string())?;
10856 }
10857 let updates = table
10858 .columns
10859 .iter()
10860 .map(|column| column.name.as_str())
10861 .chain(std::iter::once("_syncular_version"))
10862 .filter(|name| *name != table.primary_key)
10863 .map(|name| {
10864 let name = quote_ident(name);
10865 format!("{name}=excluded.{name}")
10866 })
10867 .collect::<Vec<_>>()
10868 .join(",");
10869 for chunk in keys.chunks(400) {
10870 let holes = vec!["?"; chunk.len()].join(",");
10871 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
10872 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10873 .map_err(|error| error.to_string())?;
10874 }
10875 Ok(())
10876 }
10877
10878 fn compile_local_data_purge(
10881 &self,
10882 input: &LocalDataPurgeInput,
10883 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
10884 let invalid = |message: String| format!("sync.invalid_request: {message}");
10885 if input.purge_id.is_empty()
10886 || input.purge_id.len() > 128
10887 || !is_local_operation_code_like(&input.purge_id)
10888 {
10889 return Err(invalid(
10890 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
10891 ));
10892 }
10893 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
10894 return Err(invalid(format!(
10895 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
10896 )));
10897 }
10898
10899 let mut deduplicated: BTreeMap<
10900 String,
10901 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
10902 > = BTreeMap::new();
10903 for target in &input.targets {
10904 let table = self.schema.table(&target.table).ok_or_else(|| {
10905 invalid(format!(
10906 "local purge names unknown table {:?}",
10907 target.table
10908 ))
10909 })?;
10910 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
10911 return Err(invalid(format!(
10912 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
10913 target.table
10914 )));
10915 }
10916 let mut selectors = Vec::with_capacity(target.selectors.len());
10917 let mut canonical_selectors = BTreeMap::new();
10918 for (column_name, raw_values) in &target.selectors {
10919 let Some((column_index, column)) = table
10920 .columns
10921 .iter()
10922 .enumerate()
10923 .find(|(_, column)| column.name == *column_name)
10924 else {
10925 return Err(invalid(format!(
10926 "local purge target {:?} names unknown column {:?}",
10927 target.table, column_name
10928 )));
10929 };
10930 let encrypted = table
10931 .encrypted_columns
10932 .iter()
10933 .any(|candidate| candidate.index == column_index);
10934 if column.ty != ColumnType::String || encrypted {
10935 return Err(invalid(format!(
10936 "local purge selector {:?}.{:?} must be a plaintext string column",
10937 target.table, column_name
10938 )));
10939 }
10940 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
10941 return Err(invalid(format!(
10942 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
10943 target.table, column_name
10944 )));
10945 }
10946 let mut values = raw_values.clone();
10947 values.sort();
10948 values.dedup();
10949 if values.iter().any(|value| {
10950 value.is_empty()
10951 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
10952 || !is_local_operation_code_like(value)
10953 }) {
10954 return Err(invalid(format!(
10955 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
10956 )));
10957 }
10958 selectors.push((column_name.clone(), values.clone()));
10959 canonical_selectors.insert(column_name.clone(), values);
10960 }
10961 let canonical = LocalDataPurgeTarget {
10962 table: target.table.clone(),
10963 selectors: canonical_selectors,
10964 };
10965 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10966 deduplicated.insert(
10967 key,
10968 (
10969 CompiledLocalDataPurgeTarget {
10970 table: target.table.clone(),
10971 selectors,
10972 },
10973 canonical,
10974 ),
10975 );
10976 }
10977 let targets = deduplicated
10978 .values()
10979 .map(|(compiled, _)| compiled.clone())
10980 .collect::<Vec<_>>();
10981 let canonical = deduplicated
10982 .values()
10983 .map(|(_, target)| target.clone())
10984 .collect::<Vec<_>>();
10985 let canonical_plan =
10986 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10987 Ok((targets, canonical_plan))
10988 }
10989
10990 fn local_purge_base_row_ids(
10991 &self,
10992 targets: &[CompiledLocalDataPurgeTarget],
10993 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
10994 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10995 for target in targets {
10996 let table = self
10997 .schema
10998 .table(&target.table)
10999 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
11000 let mut clauses = Vec::with_capacity(target.selectors.len());
11001 let mut params = Vec::new();
11002 for (column, values) in &target.selectors {
11003 clauses.push(format!(
11004 "{} IN ({})",
11005 quote_ident(column),
11006 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
11007 ));
11008 params.extend(values.iter().cloned().map(SqlValue::Text));
11009 }
11010 let sql = format!(
11011 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
11012 quote_ident(&table.primary_key),
11013 base_table(&target.table),
11014 clauses.join(" AND ")
11015 );
11016 let mut statement = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
11017 let rows = statement
11018 .query_map(rusqlite::params_from_iter(params), |row| {
11019 row.get::<_, String>(0)
11020 })
11021 .map_err(|error| error.to_string())?;
11022 let ids = by_table.entry(target.table.clone()).or_default();
11023 for row in rows {
11024 ids.insert(row.map_err(|error| error.to_string())?);
11025 }
11026 }
11027 Ok(by_table)
11028 }
11029
11030 fn local_purge_values_match(
11031 target: &CompiledLocalDataPurgeTarget,
11032 values: &Map<String, Value>,
11033 ) -> bool {
11034 target.selectors.iter().all(|(column, allowed)| {
11035 values
11036 .get(column)
11037 .and_then(Value::as_str)
11038 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
11039 })
11040 }
11041
11042 pub fn purge_local_data(
11046 &mut self,
11047 input: &LocalDataPurgeInput,
11048 ) -> Result<LocalDataPurgeResult, String> {
11049 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
11050 let meta_key = format!("localPurge:{}", input.purge_id);
11051 let applied_plan = self
11052 .conn
11053 .query_row(
11054 "SELECT value FROM _syncular_meta WHERE key = ?1",
11055 rusqlite::params![meta_key],
11056 |row| row.get::<_, String>(0),
11057 )
11058 .optional()
11059 .map_err(|error| error.to_string())?;
11060 if let Some(applied_plan) = applied_plan {
11061 if applied_plan != canonical_plan {
11062 return Err(format!(
11063 "sync.invalid_request: local purge id {:?} was already used with a different plan",
11064 input.purge_id
11065 ));
11066 }
11067 return Ok(LocalDataPurgeResult {
11068 already_applied: true,
11069 purged_rows: 0,
11070 dropped_commits: 0,
11071 });
11072 }
11073
11074 let _ = self.drop_previous_version();
11078
11079 let prior_outbox = self.outbox.clone();
11080 let prior_rejection_count = self.rejections.len();
11081 let prior_overlay_dirty = self.overlay_dirty.get();
11082 self.begin_observation("syncular_local_purge")?;
11083 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
11084 let row_ids = self.local_purge_base_row_ids(&targets)?;
11085 let doomed = self
11086 .outbox
11087 .iter()
11088 .filter(|commit| {
11089 commit.ops.iter().any(|operation| {
11090 let matching_targets = targets
11091 .iter()
11092 .filter(|target| target.table == operation.table)
11093 .collect::<Vec<_>>();
11094 if matching_targets.is_empty() {
11095 return false;
11096 }
11097 if row_ids
11098 .get(&operation.table)
11099 .is_some_and(|ids| ids.contains(&operation.row_id))
11100 {
11101 return true;
11102 }
11103 operation.values.as_ref().is_some_and(|values| {
11104 matching_targets
11105 .iter()
11106 .any(|target| Self::local_purge_values_match(target, values))
11107 })
11108 })
11109 })
11110 .cloned()
11111 .collect::<Vec<_>>();
11112 let doomed_ids = doomed
11113 .iter()
11114 .map(|commit| commit.client_commit_id.clone())
11115 .collect::<BTreeSet<_>>();
11116 let mut batch = ChangeAccumulator::default();
11117 let mut rejections = Vec::new();
11118 for commit in &doomed {
11119 for operation in &commit.ops {
11120 batch.table(&operation.table);
11121 }
11122 let results = commit
11123 .ops
11124 .iter()
11125 .enumerate()
11126 .map(|(op_index, operation)| {
11127 let rejection = RejectionRecord {
11128 client_commit_id: commit.client_commit_id.clone(),
11129 op_index: op_index as i32,
11130 code: "client.local_data_purged".to_owned(),
11131 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
11132 retryable: false,
11133 details: None,
11134 operation: Some(CommitOperation::from(operation)),
11135 };
11136 rejections.push(rejection.clone());
11137 CommitOperationOutcome::Error { rejection }
11138 })
11139 .collect::<Vec<_>>();
11140 self.persist_commit_outcome(
11141 &commit.client_commit_id,
11142 CommitOutcomeStatus::Rejected,
11143 &results,
11144 Some(&commit.ops),
11145 )?;
11146 self.delete_outbox_persisted(&commit.client_commit_id)?;
11147 }
11148 if !doomed.is_empty() {
11149 self.outbox
11150 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
11151 self.rejections.extend(rejections);
11152 self.prune_commit_outcomes()?;
11153 self.overlay_dirty.set(true);
11154 batch.status = true;
11155 batch.rejections = true;
11156 batch.outcomes = true;
11157 }
11158
11159 let mut purged_rows = 0usize;
11160 for (table, ids) in &row_ids {
11161 if ids.is_empty() {
11162 continue;
11163 }
11164 batch.table(table);
11165 for row_id in ids {
11166 self.delete_base_row(table, row_id)?;
11167 purged_rows += 1;
11168 }
11169 }
11170 self.rebuild_overlay_if_dirty();
11171 self.delete_unreferenced_cached_blobs();
11172 self.conn
11173 .execute(
11174 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11175 rusqlite::params![meta_key, canonical_plan],
11176 )
11177 .map_err(|error| error.to_string())?;
11178 Ok((
11179 batch,
11180 LocalDataPurgeResult {
11181 already_applied: false,
11182 purged_rows,
11183 dropped_commits: doomed.len(),
11184 },
11185 ))
11186 })();
11187 let (batch, result) = match applied {
11188 Ok(value) => value,
11189 Err(error) => {
11190 self.rollback_observation("syncular_local_purge");
11191 self.outbox = prior_outbox;
11192 self.rejections.truncate(prior_rejection_count);
11193 self.overlay_dirty.set(prior_overlay_dirty);
11194 return Err(error);
11195 }
11196 };
11197 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
11198 self.rollback_observation("syncular_local_purge");
11199 self.outbox = prior_outbox;
11200 self.rejections.truncate(prior_rejection_count);
11201 self.overlay_dirty.set(prior_overlay_dirty);
11202 return Err(error);
11203 }
11204 Ok(result)
11205 }
11206
11207 pub fn rebootstrap_local_data(
11213 &mut self,
11214 input: &LocalDataRebootstrapInput,
11215 ) -> Result<LocalDataRebootstrapResult, String> {
11216 if input.rebootstrap_id.is_empty()
11217 || input.rebootstrap_id.len() > 128
11218 || !is_local_operation_code_like(&input.rebootstrap_id)
11219 {
11220 return Err(
11221 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
11222 .to_owned(),
11223 );
11224 }
11225 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
11226 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
11227 let (retained_commits, reset_subscriptions) =
11228 decode_local_rebootstrap_receipt(&persisted)?;
11229 return Ok(LocalDataRebootstrapResult {
11230 already_applied: true,
11231 retained_commits,
11232 reset_subscriptions,
11233 });
11234 }
11235 if self.stopped || self.schema_floor.is_some() {
11236 return Err(
11237 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
11238 .to_owned(),
11239 );
11240 }
11241
11242 let retained_commits = self.outbox.len();
11243 let reset_subscriptions = self.subs.len();
11244 let prior_subs = self.subs.clone();
11245 let prior_upgrading = self.upgrading;
11246 let prior_stopped = self.stopped;
11247 let prior_schema_floor = self.schema_floor.clone();
11248 let prior_overlay_dirty = self.overlay_dirty.get();
11249 let prior_sync_needed = self.sync_needed;
11250 let prior_sync_intents = self.sync_intent_queue.clone();
11251 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
11252
11253 self.begin_observation("syncular_local_rebootstrap")?;
11254 let mut batch = ChangeAccumulator::default();
11255 let applied = (|| -> Result<(), String> {
11256 self.run_schema_reset_observed(&mut batch, false, false)?;
11257 self.conn
11258 .execute(
11259 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11260 rusqlite::params![meta_key, receipt],
11261 )
11262 .map_err(|error| error.to_string())?;
11263 self.sync_needed = true;
11264 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11265 batch.status = true;
11266 Ok(())
11267 })();
11268 if let Err(error) = applied {
11269 self.rollback_observation("syncular_local_rebootstrap");
11270 self.subs = prior_subs;
11271 self.upgrading = prior_upgrading;
11272 self.stopped = prior_stopped;
11273 self.schema_floor = prior_schema_floor;
11274 self.overlay_dirty.set(prior_overlay_dirty);
11275 self.sync_needed = prior_sync_needed;
11276 self.sync_intent_queue = prior_sync_intents;
11277 return Err(error);
11278 }
11279 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
11280 self.rollback_observation("syncular_local_rebootstrap");
11281 self.subs = prior_subs;
11282 self.upgrading = prior_upgrading;
11283 self.stopped = prior_stopped;
11284 self.schema_floor = prior_schema_floor;
11285 self.overlay_dirty.set(prior_overlay_dirty);
11286 self.sync_needed = prior_sync_needed;
11287 self.sync_intent_queue = prior_sync_intents;
11288 return Err(error);
11289 }
11290
11291 Ok(LocalDataRebootstrapResult {
11292 already_applied: false,
11293 retained_commits,
11294 reset_subscriptions,
11295 })
11296 }
11297
11298 fn purge_scope_rows(
11303 &mut self,
11304 table_name: &str,
11305 effective: &[(String, Vec<String>)],
11306 ) -> Result<(), ()> {
11307 if effective.is_empty() {
11308 return Ok(());
11309 }
11310 let table = self.schema.table(table_name).ok_or(())?.clone();
11311 let mut clauses = Vec::new();
11312 let mut params: Vec<SqlValue> = Vec::new();
11313 for (variable, values) in effective {
11314 let column = table.scope_column(variable).ok_or(())?;
11315 let placeholders: Vec<String> = values
11316 .iter()
11317 .map(|v| {
11318 params.push(SqlValue::Text(v.clone()));
11319 "?".to_owned()
11320 })
11321 .collect();
11322 clauses.push(format!(
11323 "{} IN ({})",
11324 quote_ident(column),
11325 placeholders.join(", ")
11326 ));
11327 }
11328 let sql = format!(
11329 "DELETE FROM {} WHERE {}",
11330 base_table(table_name),
11331 clauses.join(" AND ")
11332 );
11333 self.overlay_dirty.set(true);
11334 self.conn
11335 .execute(&sql, rusqlite::params_from_iter(¶ms))
11336 .map_err(|_| ())?;
11337 self.conn
11338 .execute(
11339 &format!(
11340 "DELETE FROM {} WHERE {}",
11341 visible_table(table_name),
11342 clauses.join(" AND ")
11343 ),
11344 rusqlite::params_from_iter(¶ms),
11345 )
11346 .map_err(|_| ())?;
11347 Ok(())
11348 }
11349
11350 fn drop_doomed_outbox(
11353 &mut self,
11354 table_name: &str,
11355 effective: &[(String, Vec<String>)],
11356 ) -> Result<bool, String> {
11357 if effective.is_empty() {
11358 return Ok(false);
11359 }
11360 let Some(table) = self.schema.table(table_name).cloned() else {
11361 return Ok(false);
11362 };
11363 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
11364 for (variable, values) in effective {
11365 match table.scope_column(variable) {
11366 Some(column) => mappings.push((column, values)),
11367 None => return Ok(false), }
11369 }
11370 let doomed: Vec<OutboxCommit> = self
11371 .outbox
11372 .iter()
11373 .filter(|commit| {
11374 commit.ops.iter().any(|op| {
11375 op.upsert
11376 && op.table == table_name
11377 && op.values.as_ref().is_some_and(|values| {
11378 mappings
11379 .iter()
11380 .all(|(column, allowed)| match values.get(*column) {
11381 Some(Value::String(s)) => allowed.contains(s),
11382 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
11383 _ => false,
11384 })
11385 })
11386 })
11387 })
11388 .cloned()
11389 .collect();
11390 if doomed.is_empty() {
11391 return Ok(false);
11392 }
11393 let mut rejections = Vec::new();
11394 for commit in &doomed {
11395 let results = commit
11396 .ops
11397 .iter()
11398 .enumerate()
11399 .map(|(op_index, operation)| {
11400 let rejection = RejectionRecord {
11401 client_commit_id: commit.client_commit_id.clone(),
11402 op_index: op_index as i32,
11403 code: "sync.scope_revoked".to_owned(),
11404 message: "the commit was dropped because its effective scope was revoked"
11405 .to_owned(),
11406 retryable: false,
11407 details: None,
11408 operation: Some(CommitOperation::from(operation)),
11409 };
11410 rejections.push(rejection.clone());
11411 CommitOperationOutcome::Error { rejection }
11412 })
11413 .collect::<Vec<_>>();
11414 self.persist_commit_outcome(
11415 &commit.client_commit_id,
11416 CommitOutcomeStatus::Rejected,
11417 &results,
11418 Some(&commit.ops),
11419 )?;
11420 self.delete_outbox_persisted(&commit.client_commit_id)?;
11421 }
11422 self.prune_commit_outcomes()?;
11423 let doomed_ids = doomed
11424 .iter()
11425 .map(|commit| commit.client_commit_id.as_str())
11426 .collect::<BTreeSet<_>>();
11427 self.outbox
11428 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
11429 self.rejections.extend(rejections);
11430 self.overlay_dirty.set(true);
11431 Ok(true)
11432 }
11433
11434 pub fn upload_blob(
11441 &mut self,
11442 bytes: &[u8],
11443 media_type: Option<String>,
11444 name: Option<String>,
11445 ) -> Result<Value, String> {
11446 let blob_id = blob_id_for(bytes);
11447 let now = self.clock_now_ms();
11448 let tx = self.conn.transaction().map_err(|e| e.to_string())?;
11449 tx.execute(
11450 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
11451 ON CONFLICT(blob_id) DO NOTHING",
11452 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
11453 )
11454 .map_err(|e| e.to_string())?;
11455 tx.execute(
11456 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
11457 rusqlite::params![blob_id, media_type, now],
11458 )
11459 .map_err(|e| e.to_string())?;
11460 tx.commit().map_err(|e| e.to_string())?;
11461 self.enforce_blob_cache_cap();
11462 let mut obj = Map::new();
11463 obj.insert("blobId".to_owned(), Value::from(blob_id));
11464 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
11465 if let Some(mt) = media_type {
11466 obj.insert("mediaType".to_owned(), Value::from(mt));
11467 }
11468 if let Some(n) = name {
11469 obj.insert("name".to_owned(), Value::from(n));
11470 }
11471 Ok(Value::Object(obj))
11472 }
11473
11474 pub fn fetch_blob_bytes(
11478 &mut self,
11479 transport: &mut dyn Transport,
11480 blob_id_or_ref: &str,
11481 ) -> Result<FetchedBlob, (String, String)> {
11482 let simple = |m: String| ("client.failed".to_owned(), m);
11483 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
11484 blob_id_or_ref.to_owned()
11485 } else {
11486 let value: Value = serde_json::from_str(blob_id_or_ref)
11487 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
11488 value
11489 .get("blobId")
11490 .and_then(Value::as_str)
11491 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
11492 .to_owned()
11493 };
11494 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
11495 return Ok(cached);
11496 }
11497 #[cfg(feature = "bench-internals")]
11503 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
11504 let bytes = match transport
11505 .blob_download(&blob_id)
11506 .map_err(|e| (e.code, e.message))?
11507 {
11508 BlobDownload::Bytes(bytes) => bytes,
11509 BlobDownload::Url {
11510 url,
11511 url_expires_at_ms,
11512 } => {
11513 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
11515 return Err((
11516 "sync.segment_expired".to_owned(),
11517 format!(
11518 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
11519 ),
11520 ));
11521 }
11522 transport
11523 .fetch_blob_url(&url)
11524 .map_err(|e| (e.code, e.message))?
11525 }
11526 };
11527 #[cfg(feature = "bench-internals")]
11528 drop(download_phase);
11529 #[cfg(feature = "bench-internals")]
11530 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
11531 if blob_id_for(&bytes) != blob_id {
11533 return Err(simple(format!(
11534 "blob content address mismatch for {blob_id}"
11535 )));
11536 }
11537 #[cfg(feature = "bench-internals")]
11538 drop(validate_phase);
11539 #[cfg(feature = "bench-internals")]
11540 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
11541 let now = self.clock_now_ms();
11542 self.conn
11543 .execute(
11544 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
11545 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
11546 )
11547 .map_err(|e| simple(e.to_string()))?;
11548 self.conn
11549 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
11550 .map_err(|e| simple(e.to_string()))?;
11551 self.enforce_blob_cache_cap();
11552 #[cfg(feature = "bench-internals")]
11553 drop(insert_phase);
11554 self.get_cached_blob_bytes(&blob_id)
11555 .map_err(simple)?
11556 .ok_or_else(|| simple("blob cache write failed".to_owned()))
11557 }
11558
11559 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
11560 #[cfg(feature = "bench-internals")]
11561 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
11562 let mut stmt = self
11563 .conn
11564 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
11565 .map_err(|e| e.to_string())?;
11566 let mut rows = stmt
11567 .query(rusqlite::params![blob_id])
11568 .map_err(|e| e.to_string())?;
11569 if let Some(row) = rows.next().map_err(|e| e.to_string())? {
11570 let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
11571 let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
11572 let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
11573 return Ok(Some(FetchedBlob {
11574 blob_id: blob_id.to_owned(),
11575 byte_length,
11576 bytes,
11577 media_type,
11578 }));
11579 }
11580 Ok(None)
11581 }
11582
11583 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
11585 let pending: Vec<(String, Option<String>)> = {
11586 let mut stmt = self
11587 .conn
11588 .prepare(
11589 "SELECT blob_id, media_type FROM (
11590 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
11591 UNION ALL
11592 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
11593 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
11594 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
11595 ) ORDER BY created_at_ms, blob_id",
11596 )
11597 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11598 let rows = stmt
11599 .query_map([], |row| {
11600 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
11601 })
11602 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11603 rows.collect::<Result<_, _>>()
11604 .map_err(|error| match error {
11605 rusqlite::Error::InvalidColumnType(..)
11606 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
11607 "sync.local_corrupt",
11608 "Pending blob upload metadata is invalid",
11609 ),
11610 _ => TransportError::new("client.failed", error.to_string()),
11611 })?
11612 };
11613 for (blob_id, media_type) in pending {
11614 let stored: Option<(SqlValue, SqlValue)> = self
11615 .conn
11616 .query_row(
11617 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
11618 rusqlite::params![blob_id],
11619 |row| Ok((row.get(0)?, row.get(1)?)),
11620 )
11621 .optional()
11622 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11623 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
11624 return Err(TransportError::new(
11625 "sync.local_corrupt",
11626 "Pending blob upload body is missing or corrupt",
11627 ));
11628 };
11629 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
11630 return Err(TransportError::new(
11631 "sync.local_corrupt",
11632 "Pending blob upload body is missing or corrupt",
11633 ));
11634 }
11635 self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
11636 self.conn
11637 .execute(
11638 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
11639 rusqlite::params![blob_id],
11640 )
11641 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11642 }
11643 Ok(())
11644 }
11645
11646 fn upload_one(
11653 &self,
11654 transport: &mut dyn Transport,
11655 blob_id: &str,
11656 bytes: &[u8],
11657 media_type: Option<&str>,
11658 ) -> Result<(), TransportError> {
11659 match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
11660 BlobUploadGrant::Present => return Ok(()), BlobUploadGrant::Url {
11662 url,
11663 url_expires_at_ms,
11664 } => {
11665 let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
11666 if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
11667 return Ok(());
11668 }
11669 }
11671 BlobUploadGrant::None => {
11672 }
11674 }
11675 transport.blob_upload(blob_id, bytes, media_type)
11676 }
11677
11678 fn visible_blob_ids_sql(&self) -> String {
11679 let mut selects = Vec::new();
11680 for table in &self.schema.tables {
11681 for column in table
11682 .columns
11683 .iter()
11684 .filter(|column| column.ty == ColumnType::BlobRef)
11685 {
11686 let identifier = quote_ident(&column.name);
11687 selects.push(format!(
11688 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
11689 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
11690 AND json_type({identifier}, '$.blobId') = 'text'",
11691 quote_ident(&table.name),
11692 ));
11693 }
11694 }
11695 if selects.is_empty() {
11696 "SELECT NULL AS blob_id WHERE 0".to_owned()
11697 } else {
11698 selects.join(" UNION ")
11699 }
11700 }
11701
11702 fn enforce_blob_cache_cap(&self) {
11705 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
11706 return;
11707 };
11708 let mut total: i64 = self
11709 .conn
11710 .query_row(
11711 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
11712 [],
11713 |row| row.get(0),
11714 )
11715 .unwrap_or(0);
11716 if total <= max_bytes {
11717 return;
11718 }
11719 let query = format!(
11720 "SELECT blob_id, byte_length FROM _syncular_blobs
11721 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11722 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11723 AND blob_id NOT IN ({})
11724 ORDER BY created_at_ms ASC, blob_id ASC",
11725 self.visible_blob_ids_sql(),
11726 );
11727 let candidates: Vec<(String, i64)> = {
11728 let Ok(mut stmt) = self.conn.prepare(&query) else {
11729 return;
11730 };
11731 let Ok(rows) = stmt.query_map([], |row| {
11732 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
11733 }) else {
11734 return;
11735 };
11736 rows.filter_map(Result::ok).collect()
11737 };
11738 for (blob_id, byte_length) in candidates {
11739 if total <= max_bytes {
11740 break;
11741 }
11742 let _ = self.conn.execute(
11743 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
11744 rusqlite::params![blob_id],
11745 );
11746 total -= byte_length;
11747 }
11748 }
11749
11750 fn delete_unreferenced_cached_blobs(&mut self) {
11751 #[cfg(feature = "bench-internals")]
11752 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
11753 if !self.schema_has_blobs() {
11754 return;
11755 }
11756 self.rebuild_overlay_if_dirty();
11757 let _ = self.conn.execute(
11758 &format!(
11759 "DELETE FROM _syncular_blobs
11760 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11761 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11762 AND blob_id NOT IN ({})",
11763 self.visible_blob_ids_sql(),
11764 ),
11765 [],
11766 );
11767 }
11768
11769 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
11774 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
11775 return sql.clone();
11776 }
11777 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
11778 columns.push(quote_ident("_syncular_version"));
11779 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
11780 let primary_key = quote_ident(&table.primary_key);
11781 let updates = columns
11782 .iter()
11783 .filter(|column| **column != primary_key)
11784 .map(|column| format!("{column}=excluded.{column}"))
11785 .collect::<Vec<_>>()
11786 .join(", ");
11787 let sql = format!(
11788 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
11789 columns.join(", "),
11790 placeholders.join(", ")
11791 );
11792 self.insert_sql
11793 .borrow_mut()
11794 .insert(full_table.to_owned(), sql.clone());
11795 sql
11796 }
11797
11798 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
11799 self.overlay_dirty.set(true);
11800 self.write_row(&base_table(table_name), table_name, row, version)
11801 }
11802
11803 fn write_row(
11804 &self,
11805 full_table: &str,
11806 table_name: &str,
11807 row: &Row,
11808 version: i64,
11809 ) -> Result<(), String> {
11810 #[cfg(feature = "bench-internals")]
11811 let _phase = self.benchmark_phases.start(Phase::RowWrite);
11812 let table = self
11813 .schema
11814 .table(table_name)
11815 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11816 let sql = self.insert_row_sql(full_table, table);
11817 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11818 let params = row
11819 .iter()
11820 .map(RowParam::Cell)
11821 .chain(std::iter::once(RowParam::Version(version)));
11822 stmt.execute(rusqlite::params_from_iter(params))
11823 .map_err(|e| e.to_string())?;
11824 Ok(())
11825 }
11826
11827 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
11828 let table = self
11829 .schema
11830 .table(table_name)
11831 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11832 self.overlay_dirty.set(true);
11833 let sql = format!(
11834 "DELETE FROM {} WHERE {}",
11835 base_table(table_name),
11836 row_id_predicate(table)
11837 );
11838 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11839 stmt.execute(rusqlite::params![row_id])
11840 .map_err(|e| e.to_string())?;
11841 Ok(())
11842 }
11843
11844 fn rebuild_overlay_if_dirty(&mut self) {
11847 if self.overlay_dirty.get() {
11848 self.rebuild_overlay();
11849 }
11850 }
11851
11852 fn rebuild_overlay(&mut self) {
11856 #[cfg(feature = "bench-internals")]
11857 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
11858 #[cfg(test)]
11859 self.overlay_rebuild_count
11860 .set(self.overlay_rebuild_count.get() + 1);
11861 self.exec("SAVEPOINT syncular_overlay");
11862 for table in self.schema.tables.clone() {
11863 for index in &table.fts_indexes {
11864 let _ = self.drop_fts_triggers(index);
11865 }
11866 let visible = visible_table(&table.name);
11867 let base = base_table(&table.name);
11868 self.exec(&format!("DELETE FROM {visible}"));
11869 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
11870 }
11871 for commit in &self.outbox {
11872 self.apply_outbox_ops(&commit.ops);
11873 }
11874 for table in self.schema.tables.clone() {
11875 for index in &table.fts_indexes {
11876 let _ = self.rebuild_fts_projection(&table, index);
11877 let _ = self.create_fts_triggers(&table, index);
11878 }
11879 }
11880 self.exec("RELEASE syncular_overlay");
11881 self.overlay_dirty.set(false);
11882 }
11883
11884 fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
11889 #[cfg(feature = "bench-internals")]
11890 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
11891 for op in ops {
11892 #[cfg(feature = "bench-internals")]
11893 if let Some(phase) = &mut phase {
11894 phase.unit();
11895 }
11896 let _ = self.apply_outbox_op(op);
11897 }
11898 }
11899
11900 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
11901 let Some(table) = self.schema.table(&op.table) else {
11902 return Ok(());
11903 };
11904 let visible = visible_table(&table.name);
11905 if !op.upsert {
11906 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
11907 self.conn
11908 .prepare_cached(&sql)
11909 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
11910 .map_err(|e| e.to_string())?;
11911 return Ok(());
11912 }
11913 let Some(values) = op.values.as_ref() else {
11914 return Ok(());
11915 };
11916 let full = table
11921 .columns
11922 .iter()
11923 .all(|column| values.contains_key(&column.name));
11924 let local = self.visible_row(table, &op.row_id);
11925 if local.is_none() && !full {
11926 return Ok(());
11927 }
11928 let mut row: Row = Vec::with_capacity(table.columns.len());
11929 for (index, column) in table.columns.iter().enumerate() {
11930 let incoming = match values.get(&column.name) {
11931 Some(raw) => json_to_column_value(column, Some(raw)),
11932 None => Ok(local
11933 .as_ref()
11934 .and_then(|(values, _)| values.get(index).cloned())
11935 .flatten()),
11936 };
11937 match incoming {
11938 Ok(value) => row.push(value),
11939 Err(_) => return Ok(()),
11940 }
11941 }
11942 let version = local.as_ref().map_or(-1, |(_, version)| *version);
11943 self.write_row(&visible, &table.name, &row, version)
11944 .map_err(|error| {
11945 if error.starts_with("UNIQUE constraint failed:") {
11946 "sync.constraint_violation: local write violates a unique constraint".to_owned()
11947 } else {
11948 error
11949 }
11950 })
11951 }
11952
11953 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
11956 let sql = format!(
11957 "SELECT * FROM {} WHERE {}",
11958 visible_table(&table.name),
11959 row_id_predicate(table)
11960 );
11961 let mut stmt = self.conn.prepare_cached(&sql).ok()?;
11962 let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
11963 let row = rows.next().ok()??;
11964 let mut values = Vec::with_capacity(table.columns.len());
11965 for (index, column) in table.columns.iter().enumerate() {
11966 let raw = row.get_ref(index).ok()?;
11967 let json = sql_ref_to_json(column, raw);
11968 values.push(json_to_column_value(column, Some(&json)).ok()?);
11969 }
11970 let version: i64 = row.get(table.columns.len()).ok()?;
11971 Some((values, version))
11972 }
11973
11974 fn exec(&self, sql: &str) {
11975 let _ = self.conn.execute_batch(sql);
11976 }
11977
11978 #[must_use]
11982 pub fn realtime_policy(&self) -> RealtimePolicy {
11983 self.realtime_policy
11984 }
11985
11986 #[must_use]
11988 pub fn realtime_state(&self) -> RealtimeState {
11989 self.realtime_state
11990 }
11991
11992 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
11995 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
11996 return Err(
11997 "sync.invalid_request: disconnect realtime before changing the realtime policy"
11998 .to_owned(),
11999 );
12000 }
12001 self.realtime_policy = policy;
12002 match policy {
12003 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
12004 RealtimePolicy::Required | RealtimePolicy::Optional
12005 if self.realtime_state == RealtimeState::Disabled =>
12006 {
12007 self.set_realtime_state(RealtimeState::Disconnected, None);
12008 }
12009 RealtimePolicy::Required | RealtimePolicy::Optional => {}
12010 }
12011 Ok(())
12012 }
12013
12014 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
12017 self.realtime_state = state;
12018 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
12019 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
12020 self.realtime_reason_code = None;
12021 self.realtime_retry_delay_ms = None;
12022 }
12023 }
12024
12025 #[must_use]
12026 fn realtime_connected(&self) -> bool {
12027 self.realtime_state == RealtimeState::Connected
12028 }
12029
12030 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
12031 if self.realtime_policy == RealtimePolicy::Off {
12032 return Err(
12033 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
12034 );
12035 }
12036 if self.realtime_connected() {
12037 return Ok(());
12038 }
12039 self.realtime_state = RealtimeState::Connecting;
12040 match transport.realtime_connect_for_client(&self.client_id) {
12041 Ok(()) => {
12042 self.set_realtime_state(RealtimeState::Connected, None);
12043 Ok(())
12044 }
12045 Err(error) => {
12046 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
12047 Err(format!("{}: {}", error.code, error.message))
12048 }
12049 }
12050 }
12051
12052 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
12053 if self.realtime_state == RealtimeState::Disconnected
12056 || self.realtime_state == RealtimeState::Disabled
12057 {
12058 return;
12059 }
12060 let _ = transport.realtime_close();
12061 self.set_realtime_state(
12062 if self.realtime_policy == RealtimePolicy::Off {
12063 RealtimeState::Disabled
12064 } else {
12065 RealtimeState::Disconnected
12066 },
12067 None,
12068 );
12069 self.presence.clear(); }
12071
12072 pub fn set_presence(
12078 &mut self,
12079 transport: &mut dyn Transport,
12080 scope_key: &str,
12081 doc: Option<&Value>,
12082 ) -> Result<(), String> {
12083 if !self.realtime_connected() {
12084 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
12085 }
12086 let text = encode_presence_publish(scope_key, doc);
12087 transport
12088 .realtime_send(&text)
12089 .map_err(|e| format!("{}: {}", e.code, e.message))
12090 }
12091
12092 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
12094 self.presence
12095 .get(scope_key)
12096 .map(|peers| peers.values().cloned().collect())
12097 .unwrap_or_default()
12098 }
12099
12100 fn apply_presence(
12102 &mut self,
12103 scope_key: String,
12104 kind: Option<PresenceKind>,
12105 actor_id: Option<String>,
12106 client_id: Option<String>,
12107 doc: Option<Value>,
12108 error: Option<String>,
12109 ) {
12110 if error.is_some() {
12113 return;
12114 }
12115 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
12116 return;
12117 };
12118 let peer_key = format!("{actor_id} {client_id}");
12119 match kind {
12120 PresenceKind::Leave => {
12121 if let Some(peers) = self.presence.get_mut(&scope_key) {
12122 peers.remove(&peer_key);
12123 if peers.is_empty() {
12124 self.presence.remove(&scope_key);
12125 }
12126 }
12127 }
12128 _ => {
12129 let doc = match doc {
12130 Some(Value::Object(_)) => doc.unwrap(),
12131 _ => return,
12132 };
12133 self.presence.entry(scope_key).or_default().insert(
12134 peer_key,
12135 PresencePeer {
12136 actor_id,
12137 client_id,
12138 doc,
12139 },
12140 );
12141 }
12142 }
12143 }
12144
12145 pub fn on_realtime_text(&mut self, text: &str) {
12147 match parse_control(text) {
12148 Ok(ControlMessage::Hello { requires_sync, .. }) => {
12149 if requires_sync {
12150 self.set_sync_needed(true, true);
12152 }
12153 }
12154 Ok(ControlMessage::Presence {
12155 scope_key,
12156 kind,
12157 actor_id,
12158 client_id,
12159 doc,
12160 error,
12161 ..
12162 }) => {
12163 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
12164 }
12165 Ok(ControlMessage::Wake { .. }) => {
12166 self.set_sync_needed(true, true);
12168 }
12169 _ => {}
12170 }
12171 }
12172
12173 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
12176 if self.stopped {
12177 return;
12178 }
12179 #[cfg(feature = "bench-internals")]
12180 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
12181 let message = match decode_message(bytes) {
12182 Ok(m) if m.msg_kind == MsgKind::Response => m,
12183 _ => {
12184 self.set_sync_needed(true, true);
12185 return;
12186 }
12187 };
12188 #[cfg(feature = "bench-internals")]
12189 drop(decode_phase);
12190 let mut frames = message.frames.into_iter();
12191 let mut applied_cursor: Option<i64> = None;
12192 let mut any_covered = false;
12193 let mut dropped = false;
12194 while let Some(frame) = frames.next() {
12195 let Frame::SubStart {
12196 id,
12197 status,
12198 effective_scopes,
12199 ..
12200 } = frame
12201 else {
12202 continue;
12203 };
12204 let mut body = Vec::new();
12205 let mut next_cursor: Option<i64> = None;
12206 for inner in frames.by_ref() {
12207 match inner {
12208 Frame::SubEnd {
12209 next_cursor: nc, ..
12210 } => {
12211 next_cursor = Some(nc);
12212 break;
12213 }
12214 Frame::Unknown { .. } => {}
12215 other => body.push(other),
12216 }
12217 }
12218 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
12219 dropped = true;
12220 continue;
12221 };
12222 let sub = &self.subs[sub_index];
12223 if status != SubStatus::Active
12226 || sub.state != SubState::Active
12227 || sub.bootstrap_state.is_some()
12228 || !sub.synced_once
12229 {
12230 dropped = true;
12231 continue;
12232 }
12233 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
12234 any_covered = true;
12236 continue;
12237 }
12238 let previous_effective = self.subs[sub_index].effective.clone();
12239 let previous_cursor = self.subs[sub_index].cursor;
12240 self.subs[sub_index].effective = Some(effective_scopes);
12241 let mut failed = false;
12242 for inner in body {
12243 match inner {
12244 Frame::Commit {
12245 tables, changes, ..
12246 } => {
12247 if self.apply_commit_frame(&tables, &changes).is_err() {
12248 failed = true;
12249 break;
12250 }
12251 }
12252 _ => {
12253 failed = true;
12254 break;
12255 }
12256 }
12257 }
12258 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
12259 self.apply_sub_end(sub_index, cursor, None)
12260 .ok()
12261 .map(|()| cursor)
12262 });
12263 let Some(next_cursor) = committed else {
12264 self.subs[sub_index].effective = previous_effective;
12265 self.subs[sub_index].cursor = previous_cursor;
12266 dropped = true;
12267 continue;
12268 };
12269 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
12270 }
12271 if let Some(cursor) = applied_cursor {
12272 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12274 let _ = transport.realtime_send(&ack);
12275 } else if !any_covered || dropped {
12276 self.set_sync_needed(true, true);
12278 }
12279 }
12280
12281 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
12285 if !self.realtime_connected() {
12286 return;
12287 }
12288 let floor = self
12289 .subs
12290 .iter()
12291 .filter(|s| {
12292 s.state == SubState::Active
12293 && s.bootstrap_state.is_none()
12294 && s.synced_once
12295 && s.cursor >= 0
12296 })
12297 .map(|s| s.cursor)
12298 .min();
12299 if let Some(cursor) = floor {
12300 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12301 let _ = transport.realtime_send(&ack);
12302 }
12303 }
12304}
12305
12306#[cfg(test)]
12307mod previous_version_wiring_tests {
12308 use super::*;
12309 use serde_json::{json, Value};
12310
12311 fn previous_version_schema(version: i32) -> Value {
12312 json!({
12313 "version": version,
12314 "tables": [{
12315 "name": "tasks",
12316 "primaryKey": "id",
12317 "columns": [
12318 { "name": "id", "type": "string", "nullable": false },
12319 { "name": "note", "type": "string", "nullable": true }
12320 ],
12321 "scopes": []
12322 }]
12323 })
12324 }
12325
12326 fn previous_version_limits() -> crate::api::ClientLimits {
12327 crate::api::ClientLimits {
12328 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
12329 enabled: true,
12330 ..Default::default()
12331 }),
12332 ..Default::default()
12333 }
12334 }
12335
12336 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
12338 previous_version_limits()
12339 .previous_version_context
12340 .expect("configured")
12341 }
12342
12343 fn recapture_previous_version(client: &SyncClient) {
12346 let replica_path = client
12347 .previous_version_replica_path()
12348 .expect("file-backed replica");
12349 let outcome = crate::previous_version::capture_previous_version_from_replica(
12350 &client.conn,
12351 &replica_path,
12352 2,
12353 2,
12354 &previous_version_config(),
12355 1,
12356 )
12357 .expect("capture");
12358 assert!(matches!(
12359 outcome,
12360 crate::previous_version::CaptureOutcome::Captured(_)
12361 ));
12362 }
12363
12364 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
12365 Subscription {
12366 id: "wiring-sub".to_owned(),
12367 table: "tasks".to_owned(),
12368 requested: Vec::new(),
12369 params: None,
12370 cursor,
12371 bootstrap_state: bootstrap_state.map(str::to_owned),
12372 state,
12373 reason_code: None,
12374 effective: None,
12375 synced_once: false,
12376 }
12377 }
12378
12379 #[test]
12380 fn previous_version_read_reasons_come_from_live_client_state() {
12381 use crate::previous_version::{
12382 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
12383 };
12384 let path = std::env::temp_dir()
12385 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
12386 .to_string_lossy()
12387 .into_owned();
12388 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
12389 {
12390 let mut setup = SyncClient::open_path(
12391 "wiring".to_owned(),
12392 &previous_version_schema(1),
12393 previous_version_limits(),
12394 &path,
12395 )
12396 .expect("v1 install");
12397 setup
12398 .mutate(vec![Mutation::Upsert {
12399 table: "tasks".to_owned(),
12400 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
12401 base_version: None,
12402 }])
12403 .expect("seed a row");
12404 }
12405 let mut client = SyncClient::open_path(
12406 "wiring".to_owned(),
12407 &previous_version_schema(2),
12408 previous_version_limits(),
12409 &path,
12410 )
12411 .expect("v2 bump captures");
12412 assert!(std::path::Path::new(&container).exists());
12413 let spec = PreviousVersionReadSpec {
12414 table: "tasks".to_owned(),
12415 ..Default::default()
12416 };
12417
12418 recapture_previous_version(&client);
12420 client.lease_state = Some(LeaseState {
12421 lease_id: None,
12422 expires_at_ms: None,
12423 error_code: Some("lease.expired".to_owned()),
12424 });
12425 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12426 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
12427 assert!(!snapshot.available);
12428 assert!(!std::path::Path::new(&container).exists());
12429
12430 recapture_previous_version(&client);
12432 client.set_now_ms(1_000);
12433 client.lease_state = Some(LeaseState {
12434 lease_id: Some("lease".to_owned()),
12435 expires_at_ms: Some(1_000),
12436 error_code: None,
12437 });
12438 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12439 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
12440 assert!(!std::path::Path::new(&container).exists());
12441 client.lease_state = None;
12442
12443 recapture_previous_version(&client);
12445 client.subs.push(subscription(SubState::Revoked, 0, None));
12446 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12447 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
12448 assert!(!std::path::Path::new(&container).exists());
12449 client.subs.clear();
12450
12451 recapture_previous_version(&client);
12454 client
12455 .subs
12456 .push(subscription(SubState::Active, 0, Some("resume")));
12457 assert!(!client.previous_version_coverage_complete());
12458 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12459 assert!(snapshot.available);
12460 assert_eq!(snapshot.previous_version, Some(2));
12461 assert_eq!(snapshot.rows.len(), 1);
12462
12463 recapture_previous_version(&client);
12464 client.subs[0].bootstrap_state = None;
12465 assert!(client.previous_version_coverage_complete());
12466 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12467 assert_eq!(
12468 snapshot.reason,
12469 Some(PreviousVersionReason::CoverageComplete)
12470 );
12471 assert!(!std::path::Path::new(&container).exists());
12472 client.subs.clear();
12473
12474 assert!(!client.previous_version_coverage_complete());
12476
12477 drop(client);
12478 let _ = std::fs::remove_file(&path);
12479 }
12480
12481 #[test]
12488 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
12489 let path = std::env::temp_dir()
12490 .join(format!(
12491 "syncular-prev-lifetime-{}.db",
12492 uuid::Uuid::new_v4()
12493 ))
12494 .to_string_lossy()
12495 .into_owned();
12496 let container = format!(
12497 "{path}{}",
12498 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
12499 );
12500 let exists = || std::path::Path::new(&container).exists();
12501 {
12502 let mut setup = SyncClient::open_path(
12503 "lifetime".to_owned(),
12504 &previous_version_schema(1),
12505 previous_version_limits(),
12506 &path,
12507 )
12508 .expect("v1 install");
12509 setup
12510 .mutate(vec![Mutation::Upsert {
12511 table: "tasks".to_owned(),
12512 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
12513 base_version: None,
12514 }])
12515 .expect("seed a row");
12516 }
12517 let mut client = SyncClient::open_path(
12518 "lifetime".to_owned(),
12519 &previous_version_schema(2),
12520 previous_version_limits(),
12521 &path,
12522 )
12523 .expect("v2 bump captures");
12524 client.set_now_ms(1_000);
12527 assert!(exists());
12528
12529 client.lease_state = Some(LeaseState {
12531 lease_id: None,
12532 expires_at_ms: None,
12533 error_code: Some("lease.expired".to_owned()),
12534 });
12535 client.previous_version_lifetime_check();
12536 assert!(!exists());
12537 client.lease_state = None;
12538
12539 recapture_previous_version(&client);
12542 client.subs.push(subscription(SubState::Active, 0, None));
12543 client.previous_version_lifetime_check();
12544 assert!(!exists());
12545 client.subs.clear();
12546
12547 recapture_previous_version(&client);
12549 client.subs.push(subscription(SubState::Revoked, 0, None));
12550 client.previous_version_lifetime_check();
12551 assert!(!exists());
12552 client.subs.clear();
12553
12554 recapture_previous_version(&client);
12557 client
12558 .subs
12559 .push(subscription(SubState::Active, 0, Some("resume")));
12560 client.previous_version_lifetime_check();
12561 assert!(exists());
12562 client.subs.clear();
12563
12564 drop(client);
12565 let _ = std::fs::remove_file(&path);
12566 let _ = std::fs::remove_file(&container);
12567 let _ = std::fs::remove_file(format!("{container}-wal"));
12568 let _ = std::fs::remove_file(format!("{container}-shm"));
12569 }
12570}