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 assert_eq!(
1122 client.progress().snapshot().unwrap().retry_delay_ms,
1123 Some(250)
1124 );
1125 let mut transport = ServerErrorTransport {
1126 code: "sync.invalid_request",
1127 retryable: false,
1128 };
1129 assert!(matches!(
1130 client.sync(&mut transport),
1131 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1132 ));
1133 assert!(client.drain_sync_intents().is_empty());
1134 let progress = client.progress().snapshot().unwrap();
1135 assert_eq!(progress.state, ProgressState::Failed);
1136 assert_eq!(progress.retry_delay_ms, None);
1137 }
1138
1139 #[test]
1140 fn failed_progress_retry_delay_doubles_to_the_cap() {
1141 let mut client = client();
1142 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1143 let mut transport = ServerErrorTransport {
1144 code: "sync.internal_error",
1145 retryable: true,
1146 };
1147 let delays: Vec<Option<u64>> = (0..9)
1148 .map(|_| {
1149 client.sync(&mut transport);
1150 client.progress().snapshot().unwrap().retry_delay_ms
1151 })
1152 .collect();
1153 assert_eq!(
1154 delays,
1155 [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
1156 );
1157 }
1158
1159 #[test]
1160 fn background_retry_deadlines_back_off_and_reset() {
1161 let mut client = client();
1162 client.schedule_background_retry();
1163 assert!(matches!(
1164 client.drain_sync_intents().as_slice(),
1165 [SyncIntent::Background { delay_ms: 250 }]
1166 ));
1167 client.schedule_background_retry();
1168 assert!(matches!(
1169 client.drain_sync_intents().as_slice(),
1170 [SyncIntent::Background { delay_ms: 500 }]
1171 ));
1172 client.reset_background_retry();
1173 client.schedule_background_retry();
1174 assert!(matches!(
1175 client.drain_sync_intents().as_slice(),
1176 [SyncIntent::Background { delay_ms: 250 }]
1177 ));
1178 }
1179
1180 #[test]
1181 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1182 let mut client = client();
1183 client
1184 .subscribe(
1185 "epoch-tasks".to_owned(),
1186 "tasks".to_owned(),
1187 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1188 None,
1189 )
1190 .expect("subscribe");
1191 client.drain_sync_intents();
1192
1193 client
1194 .run_log_epoch_reset("epoch-2")
1195 .expect("reset partition log epoch");
1196
1197 assert!(matches!(
1198 client.drain_sync_intents().as_slice(),
1199 [SyncIntent::Interactive]
1200 ));
1201 }
1202
1203 #[derive(Default)]
1204 struct CountingRealtimeTransport {
1205 connects: usize,
1206 closes: usize,
1207 messages: Vec<String>,
1208 blob_uploads: Vec<(String, Vec<u8>)>,
1209 blob_download: Option<Vec<u8>>,
1210 sync_calls: usize,
1211 realtime_sync_calls: usize,
1212 refuse_connect: bool,
1213 }
1214
1215 impl Transport for CountingRealtimeTransport {
1216 fn blob_upload(
1217 &mut self,
1218 blob_id: &str,
1219 bytes: &[u8],
1220 _media_type: Option<&str>,
1221 ) -> Result<(), TransportError> {
1222 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1223 Ok(())
1224 }
1225
1226 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1227 self.blob_download
1228 .clone()
1229 .map(BlobDownload::Bytes)
1230 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1231 }
1232
1233 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1234 self.sync_calls += 1;
1235 Err(TransportError::new("sync.transport_failed", "offline"))
1236 }
1237
1238 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1239 self.realtime_sync_calls += 1;
1240 Err(TransportError::new("sync.transport_failed", "offline"))
1241 }
1242
1243 fn download_segment(
1244 &mut self,
1245 _request: &SegmentRequest,
1246 _on_progress: &mut dyn FnMut(u64),
1247 ) -> Result<Vec<u8>, TransportError> {
1248 Err(TransportError::new("sync.transport_failed", "offline"))
1249 }
1250
1251 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1252 self.connects += 1;
1253 if self.refuse_connect {
1254 return Err(TransportError::new("transport.failed", "refused"));
1255 }
1256 Ok(())
1257 }
1258
1259 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1260 self.messages.push(text.to_owned());
1261 Ok(())
1262 }
1263
1264 fn realtime_close(&mut self) -> Result<(), TransportError> {
1265 self.closes += 1;
1266 Ok(())
1267 }
1268 }
1269
1270 #[test]
1271 fn blob_results_own_downloaded_bytes() {
1272 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1273 {"name":"id","type":"string","nullable":false},
1274 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1275 let bytes = b"owned typed result".to_vec();
1276 let blob_id = blob_id_for(&bytes);
1277 let mut transport = CountingRealtimeTransport {
1278 blob_download: Some(bytes.clone()),
1279 ..Default::default()
1280 };
1281 let result = {
1282 let mut client =
1283 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1284 .unwrap();
1285 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1286 assert_eq!(result.blob_id, blob_id);
1287 assert_eq!(result.byte_length, bytes.len() as i64);
1288 assert_eq!(result.bytes, bytes);
1289 assert_eq!(result.media_type, None);
1290 assert_eq!(
1291 client
1292 .conn
1293 .query_row(
1294 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1295 rusqlite::params![blob_id],
1296 |row| row.get::<_, i64>(0),
1297 )
1298 .unwrap(),
1299 0
1300 );
1301 result
1302 };
1303 assert_eq!(result.bytes, bytes);
1304
1305 let mut client =
1306 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1307 assert_eq!(
1308 client
1309 .fetch_blob_bytes(&mut transport, "not json")
1310 .unwrap_err(),
1311 (
1312 "client.failed".to_owned(),
1313 "blob ref is not JSON".to_owned()
1314 )
1315 );
1316 }
1317
1318 #[test]
1319 fn visible_blob_references_survive_cache_trimming() {
1320 let schema = json!({"version":1,"tables":[
1321 {"name":"attachments","primaryKey":"id","columns":[
1322 {"name":"id","type":"string","nullable":false},
1323 {"name":"body","type":"blob_ref","nullable":true},
1324 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1325 {"name":"avatars","primaryKey":"id","columns":[
1326 {"name":"id","type":"string","nullable":false},
1327 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1328 ]});
1329 let bytes = b"referenced above cap".to_vec();
1330 let blob_id = blob_id_for(&bytes);
1331 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1332 let mut client = SyncClient::new(
1333 "visible-blob-refs".to_owned(),
1334 &schema,
1335 ClientLimits {
1336 blob_cache_max_bytes: Some(1),
1337 ..Default::default()
1338 },
1339 )
1340 .unwrap();
1341 client.conn.execute(
1342 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1343 rusqlite::params![reference, reference, reference, reference],
1344 ).unwrap();
1345 client
1346 .conn
1347 .execute(
1348 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1349 rusqlite::params![reference],
1350 )
1351 .unwrap();
1352 client.conn.execute(
1353 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1354 [],
1355 ).unwrap();
1356 let mut transport = CountingRealtimeTransport {
1357 blob_download: Some(bytes.clone()),
1358 ..Default::default()
1359 };
1360
1361 assert_eq!(
1362 client
1363 .fetch_blob_bytes(&mut transport, &blob_id)
1364 .unwrap()
1365 .bytes,
1366 bytes
1367 );
1368 let rows = client
1369 .conn
1370 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1371 .unwrap()
1372 .query_map([], |row| row.get::<_, String>(0))
1373 .unwrap()
1374 .collect::<Result<Vec<_>, _>>()
1375 .unwrap();
1376 assert_eq!(rows, vec![blob_id]);
1377 }
1378
1379 #[test]
1380 fn realtime_connection_ownership_is_idempotent() {
1381 let mut client = client();
1382 let mut transport = CountingRealtimeTransport {
1383 connects: 0,
1384 closes: 0,
1385 ..Default::default()
1386 };
1387 client
1388 .connect_realtime(&mut transport)
1389 .expect("first connect");
1390 client
1391 .connect_realtime(&mut transport)
1392 .expect("idempotent connect");
1393 assert_eq!(transport.connects, 1);
1394 client.disconnect_realtime(&mut transport);
1395 client.disconnect_realtime(&mut transport);
1396 assert_eq!(transport.closes, 1);
1397 client
1398 .connect_realtime(&mut transport)
1399 .expect("deliberate reconnect");
1400 assert_eq!(transport.connects, 2);
1401 }
1402
1403 #[test]
1404 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1405 let mut client = client();
1406 client
1407 .set_realtime_policy(RealtimePolicy::Required)
1408 .expect("set policy");
1409 let mut transport = CountingRealtimeTransport::default();
1410 match client.sync(&mut transport) {
1411 SyncOutcome::RealtimeUnavailable {
1412 state,
1413 reason_code,
1414 retry_delay_ms,
1415 } => {
1416 assert_eq!(state, RealtimeState::Disconnected);
1417 assert_eq!(reason_code, None);
1418 assert_eq!(retry_delay_ms, 250);
1419 }
1420 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1421 }
1422 assert_eq!(transport.sync_calls, 0);
1423 assert!(matches!(
1424 client.drain_sync_intents().as_slice(),
1425 [SyncIntent::Background { delay_ms: 250 }]
1426 ));
1427 let snapshot = client
1428 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1429 .expect("diagnostics");
1430 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1431 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1432 assert_eq!(snapshot.host.realtime_reason_code, None);
1433 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1434 assert_eq!(
1435 snapshot
1436 .last_round
1437 .as_ref()
1438 .and_then(|round| round.error_code.clone()),
1439 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1440 );
1441 }
1442
1443 #[test]
1444 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1445 let mut client = client();
1446 client
1447 .set_realtime_policy(RealtimePolicy::Required)
1448 .expect("set policy");
1449 let mut transport = CountingRealtimeTransport {
1450 refuse_connect: true,
1451 ..Default::default()
1452 };
1453 assert!(client.connect_realtime(&mut transport).is_err());
1454 assert_eq!(client.realtime_state(), RealtimeState::Refused);
1455 let snapshot = client
1456 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1457 .expect("diagnostics");
1458 assert_eq!(
1459 snapshot.host.realtime_reason_code.as_deref(),
1460 Some("transport.failed")
1461 );
1462 match client.sync(&mut transport) {
1463 SyncOutcome::RealtimeUnavailable {
1464 state, reason_code, ..
1465 } => {
1466 assert_eq!(state, RealtimeState::Refused);
1467 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1468 }
1469 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1470 }
1471 assert_eq!(transport.sync_calls, 0);
1472 assert_eq!(transport.realtime_sync_calls, 0);
1473 }
1474
1475 #[test]
1476 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1477 let mut client = client();
1478 client
1479 .set_realtime_policy(RealtimePolicy::Required)
1480 .expect("set policy");
1481 let mut transport = CountingRealtimeTransport::default();
1482 client.connect_realtime(&mut transport).expect("connect");
1483 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1484 assert!(
1485 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1486 "a policy change cannot silently re-route a connected socket"
1487 );
1488 assert!(matches!(
1489 client.sync(&mut transport),
1490 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1491 ));
1492 assert_eq!(client.realtime_state(), RealtimeState::Lost);
1493 assert_eq!(transport.realtime_sync_calls, 1);
1494 assert_eq!(transport.sync_calls, 0);
1495 let snapshot = client
1496 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1497 .expect("diagnostics");
1498 assert_eq!(
1499 snapshot.host.realtime_reason_code.as_deref(),
1500 Some("sync.transport_failed")
1501 );
1502 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1503 match client.sync(&mut transport) {
1504 SyncOutcome::RealtimeUnavailable {
1505 state, reason_code, ..
1506 } => {
1507 assert_eq!(state, RealtimeState::Lost);
1508 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1509 }
1510 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1511 }
1512 assert_eq!(transport.sync_calls, 0);
1513 client.disconnect_realtime(&mut transport);
1514 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1515 assert_eq!(transport.closes, 1);
1516 client.connect_realtime(&mut transport).expect("reconnect");
1517 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1518 assert_eq!(transport.connects, 2);
1519 }
1520
1521 #[test]
1522 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1523 let mut client = client();
1524 client
1525 .set_realtime_policy(RealtimePolicy::Off)
1526 .expect("set policy");
1527 let mut transport = CountingRealtimeTransport::default();
1528 assert!(client.connect_realtime(&mut transport).is_err());
1529 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1530 assert!(matches!(
1531 client.sync(&mut transport),
1532 SyncOutcome::Failed { .. }
1533 ));
1534 assert_eq!(transport.sync_calls, 1);
1535 assert_eq!(transport.realtime_sync_calls, 0);
1536 let snapshot = client
1537 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1538 .expect("diagnostics");
1539 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1540 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1541 }
1542
1543 #[test]
1544 fn optional_policy_keeps_the_http_round() {
1545 let mut client = client();
1546 let mut transport = CountingRealtimeTransport::default();
1547 assert!(matches!(
1548 client.sync(&mut transport),
1549 SyncOutcome::Failed { .. }
1550 ));
1551 assert_eq!(transport.sync_calls, 1);
1552 assert_eq!(transport.realtime_sync_calls, 0);
1553 let snapshot = client
1554 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1555 .expect("diagnostics");
1556 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1557 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1558 }
1559
1560 #[test]
1561 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1562 let mut client = client();
1563 client
1564 .subscribe(
1565 "repair-tasks".to_owned(),
1566 "tasks".to_owned(),
1567 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1568 None,
1569 )
1570 .expect("subscribe");
1571 {
1572 let sub = client
1573 .subs
1574 .iter_mut()
1575 .find(|sub| sub.id == "repair-tasks")
1576 .expect("subscription");
1577 sub.cursor = 42;
1578 sub.synced_once = true;
1579 let persisted = sub.clone();
1580 client.persist_sub(&persisted).unwrap();
1581 }
1582 for table in ["_syncular_base_tasks", "tasks"] {
1583 client
1584 .conn
1585 .execute(
1586 &format!(
1587 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1588 ),
1589 rusqlite::params!["server-row", "p1"],
1590 )
1591 .expect("seed server row");
1592 }
1593 let pending = client
1594 .mutate(vec![Mutation::Upsert {
1595 table: "tasks".to_owned(),
1596 values: Map::from_iter([
1597 ("id".to_owned(), Value::from("offline-row")),
1598 ("project_id".to_owned(), Value::from("p1")),
1599 ]),
1600 base_version: None,
1601 }])
1602 .expect("queue offline work");
1603 client.drain_change_batches();
1604 client.drain_sync_intents();
1605
1606 assert_eq!(
1607 client
1608 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1609 rebootstrap_id: "support-case-001".to_owned(),
1610 })
1611 .expect("rebootstrap"),
1612 LocalDataRebootstrapResult {
1613 already_applied: false,
1614 retained_commits: 1,
1615 reset_subscriptions: 1,
1616 }
1617 );
1618 let visible_ids = client
1619 .conn
1620 .prepare("SELECT id FROM tasks ORDER BY id")
1621 .expect("prepare visible ids")
1622 .query_map([], |row| row.get::<_, String>(0))
1623 .expect("query visible ids")
1624 .collect::<Result<Vec<_>, _>>()
1625 .expect("collect visible ids");
1626 assert_eq!(visible_ids, vec!["offline-row"]);
1627 assert_eq!(client.pending_commit_ids(), vec![pending]);
1628 assert_eq!(
1629 client
1630 .subscription_state("repair-tasks")
1631 .expect("subscription")
1632 .cursor,
1633 -1
1634 );
1635 assert!(client.upgrading());
1636 assert!(client.sync_needed());
1637 assert!(matches!(
1638 client.drain_sync_intents().as_slice(),
1639 [SyncIntent::Interactive]
1640 ));
1641 assert_eq!(client.drain_change_batches().len(), 1);
1642
1643 assert_eq!(
1644 client
1645 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1646 rebootstrap_id: "support-case-001".to_owned(),
1647 })
1648 .expect("idempotent retry"),
1649 LocalDataRebootstrapResult {
1650 already_applied: true,
1651 retained_commits: 1,
1652 reset_subscriptions: 1,
1653 }
1654 );
1655 assert!(client.drain_change_batches().is_empty());
1656 }
1657
1658 #[test]
1659 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1660 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1661 assert_eq!(
1662 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1663 (3, 4)
1664 );
1665 assert_eq!(
1666 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1667 (0, 0)
1668 );
1669 for malformed in [
1670 "",
1671 "{}",
1672 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1673 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1674 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1675 ] {
1676 assert_eq!(
1677 decode_local_rebootstrap_receipt(malformed)
1678 .expect_err("malformed receipt must fail"),
1679 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1680 );
1681 }
1682 }
1683
1684 #[test]
1685 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1686 let path = std::env::temp_dir().join(format!(
1687 "syncular-rebootstrap-receipt-{}.db",
1688 uuid::Uuid::new_v4()
1689 ));
1690 let schema = json!({
1691 "version": 1,
1692 "tables": [{
1693 "name": "tasks",
1694 "primaryKey": "id",
1695 "columns": [
1696 { "name": "id", "type": "string", "nullable": false },
1697 { "name": "project_id", "type": "string", "nullable": false }
1698 ],
1699 "scopes": [{ "pattern": "project:{project_id}" }]
1700 }]
1701 });
1702 let path_string = path.to_str().expect("UTF-8 temp path");
1703
1704 {
1705 let mut first = SyncClient::open_path(
1706 "repair-restart-client".to_owned(),
1707 &schema,
1708 ClientLimits::default(),
1709 path_string,
1710 )
1711 .expect("first open");
1712 first
1713 .subscribe(
1714 "repair-tasks".to_owned(),
1715 "tasks".to_owned(),
1716 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1717 None,
1718 )
1719 .expect("subscribe");
1720 first
1721 .mutate(vec![Mutation::Upsert {
1722 table: "tasks".to_owned(),
1723 values: Map::from_iter([
1724 ("id".to_owned(), Value::from("offline-row")),
1725 ("project_id".to_owned(), Value::from("p1")),
1726 ]),
1727 base_version: None,
1728 }])
1729 .expect("queue offline work");
1730 assert_eq!(
1731 first
1732 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1733 rebootstrap_id: "restart-receipt".to_owned(),
1734 })
1735 .expect("first rebootstrap"),
1736 LocalDataRebootstrapResult {
1737 already_applied: false,
1738 retained_commits: 1,
1739 reset_subscriptions: 1,
1740 }
1741 );
1742 }
1743
1744 let mut reopened = SyncClient::open_path(
1745 "repair-restart-client".to_owned(),
1746 &schema,
1747 ClientLimits::default(),
1748 path_string,
1749 )
1750 .expect("reopen");
1751 reopened.drain_change_batches();
1752 reopened.drain_sync_intents();
1753 assert_eq!(
1754 reopened
1755 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1756 rebootstrap_id: "restart-receipt".to_owned(),
1757 })
1758 .expect("receipt replay"),
1759 LocalDataRebootstrapResult {
1760 already_applied: true,
1761 retained_commits: 1,
1762 reset_subscriptions: 1,
1763 }
1764 );
1765 assert!(reopened.drain_change_batches().is_empty());
1766 assert!(reopened.drain_sync_intents().is_empty());
1767 drop(reopened);
1768 std::fs::remove_file(path).expect("remove temp database");
1769 }
1770
1771 #[test]
1772 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1773 let mut malformed = client();
1774 malformed
1775 .subscribe(
1776 "repair-tasks".to_owned(),
1777 "tasks".to_owned(),
1778 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1779 None,
1780 )
1781 .expect("subscribe");
1782 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1783 malformed.drain_change_batches();
1784 malformed.drain_sync_intents();
1785 let malformed_error = malformed
1786 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1787 rebootstrap_id: "malformed".to_owned(),
1788 })
1789 .expect_err("malformed receipt must fail closed");
1790 assert_eq!(
1791 malformed_error,
1792 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1793 );
1794 assert!(!malformed.upgrading());
1795 assert_eq!(
1796 malformed
1797 .subscription_state("repair-tasks")
1798 .expect("unchanged subscription")
1799 .cursor,
1800 -1
1801 );
1802 assert!(malformed.drain_change_batches().is_empty());
1803 assert!(malformed.drain_sync_intents().is_empty());
1804
1805 let mut unreadable = client();
1806 unreadable
1807 .conn
1808 .execute(
1809 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1810 rusqlite::params!["server-row", "p1"],
1811 )
1812 .expect("seed visible row");
1813 unreadable
1814 .conn
1815 .execute("DROP TABLE _syncular_meta", [])
1816 .expect("break marker storage");
1817 let unreadable_error = unreadable
1818 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1819 rebootstrap_id: "unreadable".to_owned(),
1820 })
1821 .expect_err("unreadable marker storage must fail closed");
1822 assert_eq!(
1823 unreadable_error,
1824 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1825 );
1826 let visible_rows = unreadable
1827 .conn
1828 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1829 .expect("visible projection remains");
1830 assert_eq!(visible_rows, 1);
1831 assert!(!unreadable.upgrading());
1832 assert!(unreadable.drain_change_batches().is_empty());
1833 assert!(unreadable.drain_sync_intents().is_empty());
1834 }
1835
1836 #[test]
1837 fn local_rebootstrap_cannot_bypass_schema_floor() {
1838 let mut client = client();
1839 client.set_schema_floor(Some(SchemaFloor {
1840 required_schema_version: Some(2),
1841 latest_schema_version: Some(2),
1842 }));
1843 let error = client
1844 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1845 rebootstrap_id: "blocked-floor".to_owned(),
1846 })
1847 .expect_err("schema floor must block repair");
1848 assert!(error.contains("cannot bypass an active schema-floor stop"));
1849 }
1850
1851 #[test]
1852 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1853 let mut client = client();
1854 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1855 const COMMIT_COUNT: usize = 32;
1856
1857 for index in 0..COMMIT_COUNT {
1858 client
1859 .mutate(vec![Mutation::Upsert {
1860 table: "tasks".to_owned(),
1861 values: Map::from_iter([
1862 ("id".to_owned(), Value::from(format!("task-{index}"))),
1863 ("project_id".to_owned(), Value::from("project-1")),
1864 ]),
1865 base_version: None,
1866 }])
1867 .expect("queue commit");
1868 }
1869
1870 let (_, request_meta) = client.build_request(false);
1871 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1872 let mut frames = vec![Frame::RespHeader {
1873 required_schema_version: None,
1874 latest_schema_version: None,
1875 log_epoch: Some("epoch-1".to_owned()),
1876 reset_required: Some(false),
1877 }];
1878 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1879 |(index, client_commit_id)| Frame::PushResult {
1880 client_commit_id: client_commit_id.clone(),
1881 status: PushStatus::Applied,
1882 commit_seq: Some(index as i64 + 1),
1883 results: vec![OpResult::Applied { op_index: 0 }],
1884 },
1885 ));
1886 let response = Message {
1887 wire_version: WIRE_VERSION,
1888 msg_kind: MsgKind::Response,
1889 frames,
1890 };
1891 let mut transport =
1892 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1893
1894 client.overlay_rebuild_count.set(0);
1895 client.outcome_prune_count.set(0);
1896 let outcome = client.process_response(&mut transport, response, &request_meta);
1897 assert!(matches!(outcome, SyncOutcome::Ok(_)));
1898 assert!(client.pending_commit_ids().is_empty());
1899 assert_eq!(
1900 client.overlay_rebuild_count.get(),
1901 1,
1902 "one response must reconcile its acknowledged commits with one overlay rebuild"
1903 );
1904 assert_eq!(
1905 client.outcome_prune_count.get(),
1906 1,
1907 "one response must enforce outcome retention once"
1908 );
1909 }
1910
1911 #[test]
1912 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1913 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1914 "columns": [
1915 {"name":"id", "type":"string", "nullable":false},
1916 {"name":"project_id", "type":"string", "nullable":false},
1917 {"name":"title", "type":"string", "nullable":false}
1918 ], "scopes": [{"pattern":"project:{project_id}"}],
1919 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1920 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1921 }] });
1922 let mut incremental =
1923 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1924 let mut reference =
1925 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1926 let tables = vec!["tasks".to_owned()];
1927 for index in 0..32 {
1928 let id = format!("task-{}", index % 8);
1929 let values = Map::from_iter([
1930 ("id".to_owned(), Value::from(id.clone())),
1931 (
1932 "project_id".to_owned(),
1933 Value::from(format!("p{}", index % 2)),
1934 ),
1935 ("title".to_owned(), Value::from(format!("needle{index}"))),
1936 ]);
1937 let payload = encode_row_json(
1938 incremental.schema.table("tasks").unwrap(),
1939 &id,
1940 &values,
1941 &incremental.encryption,
1942 )
1943 .unwrap();
1944 let change = ssp2::model::Change {
1945 table_index: 0,
1946 row_id: id,
1947 op: if index % 5 == 0 {
1948 Op::Delete
1949 } else {
1950 Op::Upsert
1951 },
1952 row_version: Some(index + 1),
1953 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1954 row: Some(payload),
1955 };
1956 reference.overlay_dirty.set(true);
1957 for instance in [&mut incremental, &mut reference] {
1958 instance.begin_observation("mirror_test").unwrap();
1959 let mut batch = ChangeAccumulator::default();
1960 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1961 instance
1962 .apply_commit_changes(&tables, std::slice::from_ref(&change))
1963 .unwrap();
1964 instance.rebuild_overlay_if_dirty();
1965 instance.finish_observation("mirror_test", batch).unwrap();
1966 }
1967 for sql in [
1968 "SELECT * FROM tasks ORDER BY id",
1969 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1970 ] {
1971 assert_eq!(
1972 incremental.query(sql, &[]).unwrap(),
1973 reference.query(sql, &[]).unwrap()
1974 );
1975 }
1976 assert_eq!(incremental.local_revision(), reference.local_revision());
1977 assert_eq!(
1978 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1979 serde_json::to_value(reference.drain_change_batches()).unwrap()
1980 );
1981 }
1982 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1983 assert_eq!(reference.overlay_rebuild_count.get(), 32);
1984 }
1985
1986 #[test]
1987 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1988 let cases = [
1989 (
1990 "string",
1991 vec![
1992 json!("1"),
1993 json!("01"),
1994 json!(""),
1995 json!("nul\u{0}id"),
1996 json!("é"),
1997 ],
1998 ),
1999 (
2000 "integer",
2001 vec![
2002 json!(i64::MIN),
2003 json!(-1),
2004 json!(0),
2005 json!(1),
2006 json!(i64::MAX),
2007 ],
2008 ),
2009 ("boolean", vec![json!(false), json!(true)]),
2010 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
2011 ];
2012 for (kind, ids) in cases {
2013 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2014 "columns":[{"name":"id","type":kind,"nullable":false},
2015 {"name":"project_id","type":"string","nullable":false}],
2016 "scopes":[{"pattern":"project:{project_id}"}]}]});
2017 let mut client =
2018 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
2019 for (index, id) in ids.iter().enumerate() {
2020 let table = client.schema.table("tasks").unwrap();
2021 client
2022 .write_base_row(
2023 "tasks",
2024 &vec![
2025 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
2026 Some(ColumnValue::String(format!("p{index}"))),
2027 ],
2028 1,
2029 )
2030 .unwrap();
2031 }
2032 client.rebuild_overlay_if_dirty();
2033 for base in [true, false] {
2034 let full = if base {
2035 base_table("tasks")
2036 } else {
2037 visible_table("tasks")
2038 };
2039 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
2040 for row_id in [
2041 "1",
2042 "01",
2043 "1.0",
2044 "1.0000000000000002",
2045 "+1",
2046 "1e0",
2047 " 1",
2048 "-1",
2049 "0",
2050 "9223372036854775807",
2051 "-9223372036854775808",
2052 "true",
2053 "false",
2054 "",
2055 "nul\u{0}id",
2056 "é",
2057 "null",
2058 "{\"a\":1}",
2059 ] {
2060 let old_sql =
2061 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
2062 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
2063 let old = client
2064 .conn
2065 .prepare(&old_sql)
2066 .unwrap()
2067 .query_map([row_id], |row| row.get::<_, String>(0))
2068 .unwrap()
2069 .collect::<Result<Vec<_>, _>>()
2070 .unwrap();
2071 let new = client
2072 .conn
2073 .prepare_cached(&new_sql)
2074 .unwrap()
2075 .query_map([row_id], |row| row.get::<_, String>(0))
2076 .unwrap()
2077 .collect::<Result<Vec<_>, _>>()
2078 .unwrap();
2079 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
2080 let mut batch = ChangeAccumulator::default();
2081 assert_eq!(
2082 client.record_row_scopes(&mut batch, "tasks", row_id, base),
2083 !old.is_empty()
2084 );
2085 let expected = old.first().map(|scope| {
2086 BTreeMap::from([(
2087 "tasks".to_owned(),
2088 Some(BTreeSet::from([format!("project:{scope}")])),
2089 )])
2090 });
2091 assert_eq!(batch.tables, expected.unwrap_or_default());
2092 client
2093 .conn
2094 .execute_batch("SAVEPOINT lookup_delete")
2095 .unwrap();
2096 if base {
2097 client.delete_base_row("tasks", row_id).unwrap();
2098 } else {
2099 client.apply_outbox_ops(&[OutboxOp {
2100 upsert: false,
2101 table: "tasks".into(),
2102 row_id: row_id.into(),
2103 base_version: None,
2104 values: None,
2105 }]);
2106 }
2107 let remaining: i64 = client
2108 .conn
2109 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2110 row.get(0)
2111 })
2112 .unwrap();
2113 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2114 client
2115 .conn
2116 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2117 .unwrap();
2118 client.overlay_dirty.set(false);
2119 }
2120 for (predicate, expected) in [
2121 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2122 (predicate, "SEARCH"),
2123 ] {
2124 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2125 let details = client
2126 .conn
2127 .prepare(&sql)
2128 .unwrap()
2129 .query_map(["1"], |row| row.get::<_, String>(3))
2130 .unwrap()
2131 .collect::<Result<Vec<_>, _>>()
2132 .unwrap();
2133 assert!(
2134 details.iter().any(|detail| detail.contains(expected)),
2135 "{kind}: {details:?}"
2136 );
2137 }
2138 }
2139 }
2140 }
2141
2142 #[test]
2143 fn primary_keys_reject_every_ineligible_column_type() {
2144 for ty in ["float", "bytes", "crdt", "blob_ref"] {
2148 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2149 "columns":[{"name":"id","type":ty,"nullable":false},
2150 {"name":"project_id","type":"string","nullable":false}],
2151 "scopes":[{"pattern":"project:{project_id}"}]}]});
2152 let error =
2153 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2154 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2155 Err(error) => error,
2156 };
2157 assert!(
2158 error.contains("has an ineligible column type (§2.4)"),
2159 "{ty}: {error}"
2160 );
2161 }
2162 for ty in ["string", "integer", "boolean", "json"] {
2163 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2164 "columns":[{"name":"id","type":ty,"nullable":false},
2165 {"name":"project_id","type":"string","nullable":false}],
2166 "scopes":[{"pattern":"project:{project_id}"}]}]});
2167 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2168 .unwrap_or_else(|error| panic!("{ty}: {error}"));
2169 }
2170 }
2171
2172 #[test]
2173 fn pending_row_reconciliation_matches_full_fifo_replay() {
2174 for unique in [false, true] {
2175 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2176 "name": name, "primaryKey": "id", "columns": [
2177 {"name":"id", "type":"string", "nullable":false},
2178 {"name":"project_id", "type":"string", "nullable":false},
2179 {"name":"title", "type":"string", "nullable":false}],
2180 "scopes": [{"pattern":"project:{project_id}"}],
2181 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2182 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2183 })))});
2184 let mut incremental =
2185 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2186 let mut reference =
2187 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2188 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2189 for instance in [&mut incremental, &mut reference] {
2190 for table in &tables {
2191 for index in 0..12 {
2192 instance
2193 .write_base_row(
2194 table,
2195 &vec![
2196 Some(ColumnValue::String(format!("row{index}"))),
2197 Some(ColumnValue::String("p0".into())),
2198 Some(ColumnValue::String(format!("base{index}"))),
2199 ],
2200 1,
2201 )
2202 .unwrap();
2203 }
2204 }
2205 instance.rebuild_overlay_if_dirty();
2206 }
2207 for index in 0..80 {
2208 let mutations = (0..3)
2209 .map(|offset| {
2210 let table = tables[(index + offset) % 2].clone();
2211 let id = format!("row{}", (index + offset) % 16);
2212 if (index + offset) % 7 == 0 {
2213 Mutation::Delete {
2214 table,
2215 row_id: id,
2216 base_version: None,
2217 }
2218 } else {
2219 Mutation::Upsert {
2220 table,
2221 values: Map::from_iter([
2222 ("id".into(), Value::from(id)),
2223 ("project_id".into(), Value::from(format!("p{}", index % 3))),
2224 (
2225 "title".into(),
2226 Value::from(format!("needle{}", (index + offset) % 16)),
2227 ),
2228 ]),
2229 base_version: None,
2230 }
2231 }
2232 })
2233 .collect::<Vec<_>>();
2234 for instance in [&mut incremental, &mut reference] {
2235 instance.mutate(mutations.clone()).unwrap();
2236 }
2237 let changes = (0..3)
2240 .map(|offset| {
2241 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2242 let id = format!("row{}", (index + offset) % 16);
2243 let values = Map::from_iter([
2244 ("id".into(), Value::from(id.clone())),
2245 (
2246 "project_id".into(),
2247 Value::from(format!("p{}", (index + 1) % 3)),
2248 ),
2249 (
2250 "title".into(),
2251 Value::from(format!("remote{index}_{offset}")),
2252 ),
2253 ]);
2254 ssp2::model::Change {
2255 table_index: table_index as u16,
2256 row_id: id.clone(),
2257 op: if (index + offset) % 5 == 0 {
2258 Op::Delete
2259 } else {
2260 Op::Upsert
2261 },
2262 row_version: Some(index as i64 + 2),
2263 scopes: vec![],
2264 row: Some(
2265 encode_row_json(
2266 incremental.schema.table(&tables[table_index]).unwrap(),
2267 &id,
2268 &values,
2269 &incremental.encryption,
2270 )
2271 .unwrap(),
2272 ),
2273 }
2274 })
2275 .collect::<Vec<_>>();
2276 reference.overlay_dirty.set(true);
2277 incremental.apply_commit_frame(&tables, &changes).unwrap();
2278 reference.apply_commit_frame(&tables, &changes).unwrap();
2279 for table in &tables {
2280 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2281 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2282 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2283 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2284 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2285 }
2286 }
2287 assert_eq!(incremental.local_revision(), reference.local_revision());
2288 assert_eq!(
2289 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2290 serde_json::to_value(reference.drain_change_batches()).unwrap()
2291 );
2292 }
2293 assert_eq!(
2294 incremental.overlay_rebuild_count.get(),
2295 if unique { 81 } else { 21 }
2296 );
2297 assert_eq!(reference.overlay_rebuild_count.get(), 81);
2298 }
2299 }
2300
2301 #[test]
2302 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2303 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2304 "columns":[{"name":"id", "type":"string", "nullable":false},
2305 {"name":"title", "type":"string", "nullable":false}],
2306 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2307 let mut client =
2308 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2309 for (id, title) in [("a", "original"), ("b", "occupied")] {
2310 client
2311 .write_base_row(
2312 "tasks",
2313 &vec![
2314 Some(ColumnValue::String(id.into())),
2315 Some(ColumnValue::String(title.into())),
2316 ],
2317 1,
2318 )
2319 .unwrap();
2320 }
2321 client.rebuild_overlay_if_dirty();
2322 let pending = client
2323 .mutate(vec![Mutation::Upsert {
2324 table: "tasks".into(),
2325 values: Map::from_iter([
2326 ("id".into(), Value::from("a")),
2327 ("title".into(), Value::from("available")),
2328 ]),
2329 base_version: None,
2330 }])
2331 .unwrap();
2332 assert_eq!(
2333 client
2334 .conn
2335 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2336 .get::<_, String>(
2337 0
2338 ))
2339 .unwrap(),
2340 "available"
2341 );
2342 for (title, expected) in [("available", "original"), ("occupied", "available")] {
2343 let change = ssp2::model::Change {
2344 table_index: 0,
2345 row_id: "b".into(),
2346 op: Op::Upsert,
2347 row_version: Some(2),
2348 scopes: vec![],
2349 row: Some(
2350 encode_row_json(
2351 client.schema.table("tasks").unwrap(),
2352 "b",
2353 &Map::from_iter([
2354 ("id".into(), Value::from("b")),
2355 ("title".into(), Value::from(title)),
2356 ]),
2357 &client.encryption,
2358 )
2359 .unwrap(),
2360 ),
2361 };
2362 client
2363 .apply_commit_frame(&["tasks".into()], &[change])
2364 .unwrap();
2365 assert_eq!(
2366 client
2367 .conn
2368 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2369 .get::<_, String>(
2370 0
2371 ))
2372 .unwrap(),
2373 expected
2374 );
2375 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2376 }
2377 assert_eq!(client.overlay_rebuild_count.get(), 3);
2378 }
2379
2380 #[test]
2381 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2382 for failure in ["visible", "revision", "commit"] {
2383 let mut client = client();
2384 let id = client
2385 .mutate(vec![Mutation::Upsert {
2386 table: "tasks".into(),
2387 values: Map::from_iter([
2388 ("id".into(), Value::from("t1")),
2389 ("project_id".into(), Value::from("pending")),
2390 ]),
2391 base_version: None,
2392 }])
2393 .unwrap();
2394 client.drain_change_batches();
2395 let revision = client.local_revision();
2396 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2397 let trigger = match failure {
2398 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2399 "revision" => format!("CREATE TRIGGER fail_pending BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(ABORT, 'injected revision failure'); END"),
2400 _ => "PRAGMA foreign_keys = ON; CREATE TABLE pending_parent(id INTEGER PRIMARY KEY); CREATE TABLE pending_child(id INTEGER REFERENCES pending_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_pending AFTER INSERT ON _syncular_base_tasks BEGIN INSERT INTO pending_child VALUES(1); END".to_owned(),
2401 };
2402 client.conn.execute_batch(&trigger).unwrap();
2403 let change = ssp2::model::Change {
2404 table_index: 0,
2405 row_id: "t1".into(),
2406 op: Op::Upsert,
2407 row_version: Some(2),
2408 scopes: vec![],
2409 row: Some(
2410 encode_row_json(
2411 client.schema.table("tasks").unwrap(),
2412 "t1",
2413 &Map::from_iter([
2414 ("id".into(), Value::from("t1")),
2415 ("project_id".into(), Value::from("remote")),
2416 ]),
2417 &client.encryption,
2418 )
2419 .unwrap(),
2420 ),
2421 };
2422 assert!(client
2423 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
2424 .is_err());
2425 assert!(client.conn.is_autocommit());
2426 assert!(!client.overlay_dirty.get());
2427 assert_eq!(client.local_revision(), revision);
2428 assert!(client.drain_change_batches().is_empty());
2429 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2430 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2431 assert!(client
2432 .query("SELECT * FROM _syncular_base_tasks", &[])
2433 .unwrap()
2434 .is_empty());
2435 client
2436 .conn
2437 .execute_batch("DROP TRIGGER fail_pending")
2438 .unwrap();
2439 client
2440 .apply_commit_frame(&["tasks".into()], &[change])
2441 .unwrap();
2442 assert_eq!(client.local_revision(), revision + 1);
2443 assert_eq!(client.pending_commit_ids(), vec![id]);
2444 let mut expected = before[0].clone();
2447 expected.insert("_syncular_version".to_owned(), Value::from(2));
2448 assert_eq!(
2449 client.query("SELECT * FROM tasks", &[]).unwrap(),
2450 vec![expected]
2451 );
2452 assert_eq!(
2453 client
2454 .conn
2455 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2456 .get::<_, String>(0))
2457 .unwrap(),
2458 "remote"
2459 );
2460 }
2461 }
2462
2463 #[test]
2464 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2465 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2466 "columns": [
2467 {"name":"id", "type":"string", "nullable":false},
2468 {"name":"project_id", "type":"string", "nullable":false},
2469 {"name":"title", "type":"string", "nullable":false}],
2470 "scopes": [{"pattern":"project:{project_id}"}],
2471 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2472 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2473 let mut incremental =
2474 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2475 let mut reference =
2476 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2477 for instance in [&mut incremental, &mut reference] {
2478 for index in 0..16 {
2479 instance
2480 .write_base_row(
2481 "tasks",
2482 &vec![
2483 Some(ColumnValue::String(format!("task{index}"))),
2484 Some(ColumnValue::String("p0".to_owned())),
2485 Some(ColumnValue::String(format!("base{index}"))),
2486 ],
2487 1,
2488 )
2489 .unwrap();
2490 }
2491 instance.rebuild_overlay_if_dirty();
2492 instance.overlay_rebuild_count.set(0);
2493 }
2494 for index in 0..100 {
2495 let mutations = (0..1 + index % 4)
2496 .map(|offset| {
2497 let id = format!("task{}", (index + offset) % 24);
2498 if (index + offset) % 7 == 0 {
2499 Mutation::Delete {
2500 table: "tasks".to_owned(),
2501 row_id: id,
2502 base_version: None,
2503 }
2504 } else {
2505 Mutation::Upsert {
2506 table: "tasks".to_owned(),
2507 values: Map::from_iter([
2508 ("id".to_owned(), Value::from(id)),
2509 (
2510 "project_id".to_owned(),
2511 Value::from(format!("p{}", index % 2)),
2512 ),
2513 (
2514 "title".to_owned(),
2515 Value::from(format!("needle{}", (index + offset) % 24)),
2516 ),
2517 ]),
2518 base_version: None,
2519 }
2520 }
2521 })
2522 .collect::<Vec<_>>();
2523 reference.overlay_dirty.set(true);
2524 incremental.mutate(mutations.clone()).unwrap();
2525 reference.mutate(mutations).unwrap();
2526 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2527 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2528 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2529 }
2530 assert_eq!(incremental.local_revision(), reference.local_revision());
2531 assert_eq!(
2532 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2533 serde_json::to_value(reference.drain_change_batches()).unwrap()
2534 );
2535 }
2536 assert_eq!(incremental.outbox.len(), 100);
2537 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2538 assert_eq!(reference.overlay_rebuild_count.get(), 100);
2539 }
2540
2541 #[test]
2542 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2543 for fail_at in ["outbox", "revision"] {
2544 let mut client = client();
2545 let values = Map::from_iter([
2546 ("id".to_owned(), Value::from("t1")),
2547 ("project_id".to_owned(), Value::from("p1")),
2548 ]);
2549 let mutation = Mutation::Upsert {
2550 table: "tasks".to_owned(),
2551 values,
2552 base_version: None,
2553 };
2554 let trigger = if fail_at == "outbox" {
2555 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2556 } else {
2557 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2558 };
2559 client.conn.execute_batch(&trigger).unwrap();
2560 let revision = client.local_revision();
2561 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2562 assert_eq!(client.local_revision(), revision);
2563 assert!(client.outbox.is_empty());
2564 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2565 assert_eq!(
2566 client
2567 .conn
2568 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2569 .get::<_, i64>(0))
2570 .unwrap(),
2571 0
2572 );
2573 assert!(client.drain_change_batches().is_empty());
2574 assert!(!client.overlay_dirty.get());
2575 client
2576 .conn
2577 .execute_batch("DROP TRIGGER fail_append")
2578 .unwrap();
2579 client.mutate(vec![mutation]).unwrap();
2580 assert_eq!(client.outbox.len(), 1);
2581 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2582 }
2583 }
2584
2585 #[test]
2586 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2587 let mut client = client();
2588 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2589 let values = Map::from_iter([
2590 ("id".to_owned(), Value::from("t1")),
2591 ("project_id".to_owned(), Value::from("p1")),
2592 ]);
2593 let payload = encode_row_json(
2594 client.schema.table("tasks").unwrap(),
2595 "t1",
2596 &values,
2597 &client.encryption,
2598 )
2599 .unwrap();
2600 let first = ssp2::model::Change {
2601 table_index: 0,
2602 row_id: "t1".to_owned(),
2603 op: Op::Upsert,
2604 row_version: Some(1),
2605 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2606 row: Some(payload),
2607 };
2608 let mut invalid = first.clone();
2609 invalid.table_index = 1;
2610 let revision = client.local_revision();
2611 client.begin_observation("failed_mirror").unwrap();
2612 assert!(client
2613 .apply_commit_changes(&tables, &[first, invalid])
2614 .is_err());
2615 client.rollback_observation("failed_mirror");
2616 client.rebuild_overlay_if_dirty();
2617 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2618 assert_eq!(
2619 client
2620 .conn
2621 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2622 .get::<_, i64>(
2623 0
2624 ))
2625 .unwrap(),
2626 0
2627 );
2628 assert_eq!(client.local_revision(), revision);
2629 assert!(client.drain_change_batches().is_empty());
2630 }
2631
2632 #[test]
2633 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2634 for failure in ["journal", "revision", "commit", "later-frame"] {
2635 let mut client = client();
2636 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2637 for id in ["first", "second"] {
2638 client
2639 .mutate(vec![Mutation::Upsert {
2640 table: "tasks".to_owned(),
2641 values: Map::from_iter([
2642 ("id".to_owned(), Value::from(id)),
2643 ("project_id".to_owned(), Value::from("p1")),
2644 ]),
2645 base_version: None,
2646 }])
2647 .unwrap();
2648 }
2649 let (_, meta) = client.build_request(false);
2650 client
2652 .mutate(vec![Mutation::Upsert {
2653 table: "tasks".to_owned(),
2654 values: Map::from_iter([
2655 ("id".to_owned(), Value::from("later")),
2656 ("project_id".to_owned(), Value::from("p1")),
2657 ]),
2658 base_version: None,
2659 }])
2660 .unwrap();
2661 let ids = client.pending_commit_ids();
2662 let rows = client
2663 .query("SELECT * FROM tasks ORDER BY id", &[])
2664 .unwrap();
2665 let revision = client.local_revision();
2666 client.drain_change_batches();
2667 if failure != "later-frame" {
2668 client.conn.execute_batch(match failure {
2669 "journal" => "CREATE TRIGGER fail_run BEFORE INSERT ON _syncular_commit_outcomes WHEN (SELECT count(*) FROM _syncular_commit_outcomes) = 1 BEGIN SELECT RAISE(FAIL, 'injected second journal failure'); END",
2670 "revision" => "CREATE TRIGGER fail_run BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' AND (SELECT count(*) FROM _syncular_commit_outcomes) = 2 BEGIN SELECT RAISE(FAIL, 'injected run revision failure'); END",
2671 _ => "PRAGMA foreign_keys = ON; CREATE TABLE ack_parent(id INTEGER PRIMARY KEY); CREATE TABLE ack_child(id INTEGER REFERENCES ack_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_run AFTER INSERT ON _syncular_commit_outcomes WHEN (SELECT count(*) FROM _syncular_commit_outcomes) = 2 BEGIN INSERT INTO ack_child VALUES (1); END",
2672 }).unwrap();
2673 }
2674 let mut response = Message {
2675 wire_version: WIRE_VERSION,
2676 msg_kind: MsgKind::Response,
2677 frames: vec![Frame::RespHeader {
2678 required_schema_version: None,
2679 latest_schema_version: None,
2680 log_epoch: Some("epoch-1".to_owned()),
2681 reset_required: Some(false),
2682 }],
2683 };
2684 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2685 response.frames.push(Frame::PushResult {
2686 client_commit_id: id.to_owned(),
2687 status: PushStatus::Applied,
2688 commit_seq: Some(1),
2689 results: vec![OpResult::Applied { op_index: 0 }],
2690 });
2691 }
2692 if failure == "later-frame" {
2693 response.frames.push(Frame::Error {
2694 code: "sync.invalid_request".to_owned(),
2695 message: "later error".to_owned(),
2696 category: "protocol".to_owned(),
2697 retryable: false,
2698 recommended_action: "retry".to_owned(),
2699 details: None,
2700 });
2701 }
2702 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2703 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2704 assert!(
2705 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2706 );
2707 if failure == "later-frame" {
2708 assert_eq!(client.pending_commit_ids(), ids[2..]);
2709 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2710 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2711 let batches = client.drain_change_batches();
2712 assert_eq!(batches.len(), 1);
2713 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2714 assert_eq!(client.local_revision(), revision + 1);
2715 response.frames.pop();
2716 } else {
2717 assert_eq!(client.pending_commit_ids(), ids);
2718 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2719 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2720 assert_eq!(client.local_revision(), revision);
2721 assert!(client.drain_change_batches().is_empty());
2722 assert_eq!(
2723 client
2724 .query("SELECT * FROM tasks ORDER BY id", &[])
2725 .unwrap(),
2726 rows
2727 );
2728 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2729 }
2730 for frame in &mut response.frames {
2731 if let Frame::PushResult { status, .. } = frame {
2732 *status = PushStatus::Cached;
2733 }
2734 }
2735 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2736 else {
2737 panic!("retry failed");
2738 };
2739 assert_eq!(
2740 report.applied,
2741 if failure == "later-frame" {
2742 vec![]
2743 } else {
2744 ids[..2].to_vec()
2745 }
2746 );
2747 assert_eq!(client.pending_commit_ids(), ids[2..]);
2748 let batches = client.drain_change_batches();
2749 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2750 if let Some(batch) = batches.first() {
2751 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2752 }
2753 assert_eq!(client.local_revision(), revision + 1);
2754 }
2755 }
2756
2757 #[test]
2758 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2759 for failure in ["revision", "journal", "commit"] {
2760 for status in ["applied", "cached", "rejected", "conflict"] {
2761 let mut client = client();
2762 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2763 for id in ["first", "later"] {
2764 client
2765 .mutate(vec![Mutation::Upsert {
2766 table: "tasks".to_owned(),
2767 values: Map::from_iter([
2768 ("id".to_owned(), Value::from(id)),
2769 ("project_id".to_owned(), Value::from("p1")),
2770 ]),
2771 base_version: None,
2772 }])
2773 .unwrap();
2774 }
2775 let (_, meta) = client.build_request(false);
2776 let ids = client.pending_commit_ids();
2777 let rows = client
2778 .query("SELECT * FROM tasks ORDER BY id", &[])
2779 .unwrap();
2780 let revision = client.local_revision();
2781 client.drain_change_batches();
2782 let (push_status, results) = match status {
2783 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2784 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2785 "conflict" => (
2786 PushStatus::Rejected,
2787 vec![OpResult::Conflict {
2788 op_index: 0,
2789 code: "sync.version_conflict".to_owned(),
2790 message: "conflict".to_owned(),
2791 server_version: 1,
2792 server_row: encode_row_json(
2793 client.schema.table("tasks").unwrap(),
2794 "first",
2795 &Map::from_iter([
2796 ("id".to_owned(), Value::from("first")),
2797 ("project_id".to_owned(), Value::from("p1")),
2798 ]),
2799 &client.encryption,
2800 )
2801 .unwrap(),
2802 conflict_columns: Vec::new(),
2803 }],
2804 ),
2805 _ => (
2806 PushStatus::Rejected,
2807 vec![OpResult::Error {
2808 op_index: 0,
2809 code: "sync.validation_failed".to_owned(),
2810 message: "rejected".to_owned(),
2811 retryable: false,
2812 }],
2813 ),
2814 };
2815 let response = Message {
2816 wire_version: WIRE_VERSION,
2817 msg_kind: MsgKind::Response,
2818 frames: vec![
2819 Frame::RespHeader {
2820 required_schema_version: None,
2821 latest_schema_version: None,
2822 log_epoch: Some("epoch-1".to_owned()),
2823 reset_required: Some(false),
2824 },
2825 Frame::PushResult {
2826 client_commit_id: ids[0].clone(),
2827 status: push_status,
2828 commit_seq: Some(1),
2829 results,
2830 },
2831 ],
2832 };
2833 client.conn.execute_batch(match failure {
2834 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2835 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2836 _ => "PRAGMA foreign_keys = ON; CREATE TABLE ack_parent(id INTEGER PRIMARY KEY); CREATE TABLE ack_child(id INTEGER REFERENCES ack_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_ack AFTER INSERT ON _syncular_commit_outcomes BEGIN INSERT INTO ack_child VALUES (1); END",
2837 }).unwrap();
2838 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2839 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2840 assert_eq!(
2841 client.pending_commit_ids(),
2842 ids,
2843 "{failure}/{status}: pending identity"
2844 );
2845 assert_eq!(
2846 client.local_revision(),
2847 revision,
2848 "{failure}/{status}: revision"
2849 );
2850 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2851 assert!(client.conflicts().is_empty());
2852 assert!(client.rejections().is_empty());
2853 assert!(client.drain_change_batches().is_empty());
2854 assert_eq!(
2855 client
2856 .query("SELECT * FROM tasks ORDER BY id", &[])
2857 .unwrap(),
2858 rows
2859 );
2860 assert!(
2861 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2862 "{failure}/{status}: failed round"
2863 );
2864 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2865 assert!(matches!(
2866 client.process_response(&mut transport, response, &meta),
2867 SyncOutcome::Ok(_)
2868 ));
2869 assert_eq!(client.pending_commit_ids(), ids[1..]);
2870 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2871 assert_eq!(client.drain_change_batches().len(), 1);
2872 }
2873 }
2874 }
2875
2876 #[test]
2877 fn mixed_push_results_reconcile_and_prune_once_per_response() {
2878 let mut client = client();
2879 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2880 for index in 0..4 {
2881 client
2882 .mutate(vec![Mutation::Upsert {
2883 table: "tasks".to_owned(),
2884 values: Map::from_iter([
2885 ("id".to_owned(), Value::from(format!("task-{index}"))),
2886 ("project_id".to_owned(), Value::from("project-1")),
2887 ]),
2888 base_version: None,
2889 }])
2890 .expect("queue commit");
2891 }
2892
2893 let (_, request_meta) = client.build_request(false);
2894 let ids = &request_meta.pushed_ids;
2895 assert_eq!(ids.len(), 4);
2896 let response = Message {
2897 wire_version: WIRE_VERSION,
2898 msg_kind: MsgKind::Response,
2899 frames: vec![
2900 Frame::RespHeader {
2901 required_schema_version: None,
2902 latest_schema_version: None,
2903 log_epoch: Some("epoch-1".to_owned()),
2904 reset_required: Some(false),
2905 },
2906 Frame::PushResult {
2907 client_commit_id: ids[0].clone(),
2908 status: PushStatus::Applied,
2909 commit_seq: Some(1),
2910 results: vec![OpResult::Applied { op_index: 0 }],
2911 },
2912 Frame::PushResult {
2913 client_commit_id: ids[1].clone(),
2914 status: PushStatus::Cached,
2915 commit_seq: Some(2),
2916 results: vec![OpResult::Applied { op_index: 0 }],
2917 },
2918 Frame::PushResult {
2919 client_commit_id: ids[2].clone(),
2920 status: PushStatus::Rejected,
2921 commit_seq: None,
2922 results: vec![OpResult::Error {
2923 op_index: 0,
2924 code: "sync.validation_failed".to_owned(),
2925 message: "rejected".to_owned(),
2926 retryable: false,
2927 }],
2928 },
2929 Frame::PushResult {
2930 client_commit_id: ids[3].clone(),
2931 status: PushStatus::Rejected,
2932 commit_seq: None,
2933 results: vec![OpResult::Error {
2934 op_index: 0,
2935 code: "sync.idempotency_cache_miss".to_owned(),
2936 message: "retry".to_owned(),
2937 retryable: true,
2938 }],
2939 },
2940 ],
2941 };
2942 let mut transport =
2943 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2944
2945 client.overlay_rebuild_count.set(0);
2946 client.outcome_prune_count.set(0);
2947 let outcome = client.process_response(&mut transport, response, &request_meta);
2948 let SyncOutcome::Ok(report) = outcome else {
2949 panic!("mixed push-result response failed");
2950 };
2951
2952 assert_eq!(report.applied, ids[..2]);
2953 assert_eq!(report.rejected, ids[2..3]);
2954 assert_eq!(report.retryable, ids[3..4]);
2955 assert_eq!(client.pending_commit_ids(), ids[3..4]);
2956 assert_eq!(
2957 client
2958 .query("SELECT id FROM tasks ORDER BY id", &[])
2959 .expect("query visible overlay"),
2960 vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2961 );
2962 assert_eq!(client.overlay_rebuild_count.get(), 1);
2963 assert_eq!(client.outcome_prune_count.get(), 1);
2964 }
2965
2966 #[test]
2967 fn secondary_unique_collision_preserves_existing_synced_row() {
2968 let client = SyncClient::new(
2969 "unique-upsert-test".to_owned(),
2970 &json!({
2971 "version": 1,
2972 "tables": [{
2973 "name": "tasks",
2974 "primaryKey": "id",
2975 "columns": [
2976 { "name": "id", "type": "string", "nullable": false },
2977 { "name": "project_id", "type": "string", "nullable": false },
2978 { "name": "title", "type": "string", "nullable": false }
2979 ],
2980 "scopes": [{ "pattern": "project:{project_id}" }],
2981 "indexes": [{
2982 "name": "tasks_by_project_title",
2983 "columns": ["project_id", "title"],
2984 "unique": true
2985 }]
2986 }]
2987 }),
2988 ClientLimits::default(),
2989 )
2990 .expect("test client");
2991 let table = client.schema.table("tasks").expect("tasks table");
2992 let sql = client.insert_row_sql(&base_table("tasks"), table);
2993
2994 client
2995 .conn
2996 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2997 .expect("insert first row");
2998 client
2999 .conn
3000 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
3001 .expect("update same primary key");
3002 client
3003 .conn
3004 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
3005 .expect("insert second row");
3006 assert!(client
3007 .conn
3008 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
3009 .is_err());
3010
3011 let rows = client
3012 .conn
3013 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
3014 .expect("prepare rows")
3015 .query_map([], |row| {
3016 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3017 })
3018 .expect("query rows")
3019 .collect::<Result<Vec<_>, _>>()
3020 .expect("collect rows");
3021 assert_eq!(
3022 rows,
3023 vec![
3024 ("t1".to_owned(), "updated".to_owned()),
3025 ("t2".to_owned(), "original".to_owned())
3026 ]
3027 );
3028 }
3029
3030 #[test]
3031 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
3032 let path = std::env::temp_dir().join(format!(
3033 "syncular-startup-intent-{}.db",
3034 uuid::Uuid::new_v4()
3035 ));
3036 let schema = json!({
3037 "version": 1,
3038 "tables": [{
3039 "name": "tasks",
3040 "primaryKey": "id",
3041 "columns": [
3042 { "name": "id", "type": "string", "nullable": false },
3043 { "name": "project_id", "type": "string", "nullable": false }
3044 ],
3045 "scopes": [{ "pattern": "project:{project_id}" }]
3046 }]
3047 });
3048
3049 {
3050 let mut first = SyncClient::open_path_with_identity(
3051 None,
3052 &schema,
3053 ClientLimits::default(),
3054 path.to_str().expect("UTF-8 temp path"),
3055 )
3056 .expect("first open");
3057 first
3058 .set_window(
3059 &WindowBase {
3060 table: "tasks".to_owned(),
3061 variable: "project_id".to_owned(),
3062 fixed_scopes: Vec::new(),
3063 params: None,
3064 },
3065 &["persisted".to_owned()],
3066 )
3067 .expect("persist window");
3068 }
3069
3070 let mut reopened = SyncClient::open_path_with_identity(
3071 None,
3072 &schema,
3073 ClientLimits::default(),
3074 path.to_str().expect("UTF-8 temp path"),
3075 )
3076 .expect("reopen");
3077 assert!(reopened.sync_needed());
3078 assert!(matches!(
3079 reopened.drain_sync_intents().as_slice(),
3080 [SyncIntent::Interactive]
3081 ));
3082 drop(reopened);
3083 std::fs::remove_file(path).expect("remove temp database");
3084 }
3085
3086 #[test]
3087 fn reopening_preserves_immutable_subscription_identity_and_progress() {
3088 let path = std::env::temp_dir().join(format!(
3089 "syncular-subscription-identity-{}.db",
3090 uuid::Uuid::new_v4()
3091 ));
3092 let schema = json!({
3093 "version": 1,
3094 "tables": [
3095 {
3096 "name": "tasks",
3097 "primaryKey": "id",
3098 "columns": [
3099 { "name": "id", "type": "string", "nullable": false },
3100 { "name": "project_id", "type": "string", "nullable": false }
3101 ],
3102 "scopes": [{ "pattern": "project:{project_id}" }]
3103 },
3104 {
3105 "name": "docs",
3106 "primaryKey": "id",
3107 "columns": [
3108 { "name": "id", "type": "string", "nullable": false },
3109 { "name": "org_id", "type": "string", "nullable": false },
3110 { "name": "project_id", "type": "string", "nullable": false }
3111 ],
3112 "scopes": [
3113 { "pattern": "org:{org_id}" },
3114 { "pattern": "project:{projectId}", "column": "project_id" }
3115 ]
3116 }
3117 ]
3118 });
3119
3120 {
3121 let mut first = SyncClient::open_path_with_identity(
3122 None,
3123 &schema,
3124 ClientLimits::default(),
3125 path.to_str().expect("UTF-8 temp path"),
3126 )
3127 .expect("first open");
3128 first
3129 .subscribe(
3130 "stable-subscription".to_owned(),
3131 "tasks".to_owned(),
3132 vec![(
3133 "project_id".to_owned(),
3134 vec!["p2".to_owned(), "p1".to_owned()],
3135 )],
3136 Some(r#"{"view":"v1"}"#.to_owned()),
3137 )
3138 .expect("persist subscription");
3139 let persisted = {
3140 let subscription = first
3141 .subs
3142 .iter_mut()
3143 .find(|subscription| subscription.id == "stable-subscription")
3144 .expect("subscription");
3145 subscription.cursor = 41;
3146 subscription.bootstrap_state = Some("resume-token".to_owned());
3147 subscription.effective = Some(vec![(
3148 "project_id".to_owned(),
3149 vec!["p1".to_owned(), "p2".to_owned()],
3150 )]);
3151 subscription.synced_once = true;
3152 subscription.clone()
3153 };
3154 first.persist_sub(&persisted).unwrap();
3155 }
3156
3157 let mut reopened = SyncClient::open_path_with_identity(
3158 None,
3159 &schema,
3160 ClientLimits::default(),
3161 path.to_str().expect("UTF-8 temp path"),
3162 )
3163 .expect("reopen");
3164 let progress = reopened
3165 .subscription_state("stable-subscription")
3166 .expect("persisted state");
3167 assert_eq!(progress.cursor, 41);
3168 assert!(progress.has_resume_token);
3169
3170 reopened
3171 .subscribe(
3172 "stable-subscription".to_owned(),
3173 "tasks".to_owned(),
3174 vec![(
3175 "project_id".to_owned(),
3176 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3177 )],
3178 Some(r#"{"view":"v1"}"#.to_owned()),
3179 )
3180 .expect("canonical intent is idempotent");
3181 assert_eq!(
3182 reopened
3183 .subscription_state("stable-subscription")
3184 .expect("unchanged state")
3185 .cursor,
3186 progress.cursor
3187 );
3188
3189 for (table, scopes, params) in [
3190 (
3191 "tasks",
3192 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3193 Some(r#"{"view":"v1"}"#.to_owned()),
3194 ),
3195 (
3196 "tasks",
3197 vec![(
3198 "project_id".to_owned(),
3199 vec!["p2".to_owned(), "p1".to_owned()],
3200 )],
3201 Some(r#"{"view":"v2"}"#.to_owned()),
3202 ),
3203 (
3204 "docs",
3205 vec![
3206 ("org_id".to_owned(), vec!["o1".to_owned()]),
3207 ("projectId".to_owned(), vec!["p1".to_owned()]),
3208 ],
3209 Some(r#"{"view":"v1"}"#.to_owned()),
3210 ),
3211 ] {
3212 let error = reopened
3213 .subscribe(
3214 "stable-subscription".to_owned(),
3215 table.to_owned(),
3216 scopes,
3217 params,
3218 )
3219 .expect_err("identity rebind must fail");
3220 assert!(error.starts_with("client.subscription_intent_mismatch:"));
3221 assert_eq!(
3222 reopened
3223 .subscription_state("stable-subscription")
3224 .expect("unchanged state")
3225 .cursor,
3226 progress.cursor
3227 );
3228 }
3229
3230 drop(reopened);
3231 std::fs::remove_file(path).expect("remove temp database");
3232 }
3233
3234 #[test]
3235 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
3236 let path = std::env::temp_dir().join(format!(
3237 "syncular-satisfied-schema-floor-{}.db",
3238 uuid::Uuid::new_v4()
3239 ));
3240 let schema = json!({
3241 "version": 23,
3242 "tables": [{
3243 "name": "tasks",
3244 "primaryKey": "id",
3245 "columns": [
3246 { "name": "id", "type": "string", "nullable": false },
3247 { "name": "project_id", "type": "string", "nullable": false }
3248 ],
3249 "scopes": [{ "pattern": "project:{project_id}" }]
3250 }]
3251 });
3252
3253 {
3254 let mut first = SyncClient::open_path_with_identity(
3255 None,
3256 &schema,
3257 ClientLimits::default(),
3258 path.to_str().expect("UTF-8 temp path"),
3259 )
3260 .expect("first open");
3261 first
3262 .subscribe(
3263 "tasks".to_owned(),
3264 "tasks".to_owned(),
3265 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3266 None,
3267 )
3268 .expect("persist subscription");
3269 first.set_schema_floor(Some(SchemaFloor {
3270 required_schema_version: Some(22),
3271 latest_schema_version: Some(22),
3272 }));
3273 }
3274
3275 let mut reopened = SyncClient::open_path_with_identity(
3276 None,
3277 &schema,
3278 ClientLimits::default(),
3279 path.to_str().expect("UTF-8 temp path"),
3280 )
3281 .expect("reopen");
3282 assert!(reopened.schema_floor().is_none());
3283 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3284 assert!(reopened.sync_needed());
3285 assert!(matches!(
3286 reopened.drain_sync_intents().as_slice(),
3287 [SyncIntent::Interactive]
3288 ));
3289 drop(reopened);
3290 std::fs::remove_file(path).expect("remove temp database");
3291 }
3292
3293 #[test]
3294 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3295 let path = std::env::temp_dir().join(format!(
3296 "syncular-unsatisfied-schema-floor-{}.db",
3297 uuid::Uuid::new_v4()
3298 ));
3299 let schema = json!({
3300 "version": 1,
3301 "tables": [{
3302 "name": "tasks",
3303 "primaryKey": "id",
3304 "columns": [
3305 { "name": "id", "type": "string", "nullable": false },
3306 { "name": "project_id", "type": "string", "nullable": false }
3307 ],
3308 "scopes": [{ "pattern": "project:{project_id}" }]
3309 }]
3310 });
3311
3312 {
3313 let mut first = SyncClient::open_path_with_identity(
3314 None,
3315 &schema,
3316 ClientLimits::default(),
3317 path.to_str().expect("UTF-8 temp path"),
3318 )
3319 .expect("first open");
3320 first
3321 .subscribe(
3322 "tasks".to_owned(),
3323 "tasks".to_owned(),
3324 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3325 None,
3326 )
3327 .expect("persist subscription");
3328 first.set_schema_floor(Some(SchemaFloor {
3329 required_schema_version: Some(2),
3330 latest_schema_version: Some(2),
3331 }));
3332 }
3333
3334 let reopened = SyncClient::open_path_with_identity(
3335 None,
3336 &schema,
3337 ClientLimits::default(),
3338 path.to_str().expect("UTF-8 temp path"),
3339 )
3340 .expect("reopen");
3341 assert_eq!(
3342 reopened.schema_floor(),
3343 Some(&SchemaFloor {
3344 required_schema_version: Some(2),
3345 latest_schema_version: Some(2),
3346 })
3347 );
3348 assert!(!reopened.sync_needed());
3349 drop(reopened);
3350 std::fs::remove_file(path).expect("remove temp database");
3351 }
3352
3353 #[test]
3354 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3355 let path = std::env::temp_dir().join(format!(
3356 "syncular-indexed-column-bump-{}.db",
3357 uuid::Uuid::new_v4()
3358 ));
3359 let old_schema = json!({
3360 "version": 1,
3361 "tables": [
3362 {
3363 "name": "tasks",
3364 "primaryKey": "id",
3365 "columns": [
3366 { "name": "id", "type": "string", "nullable": false },
3367 { "name": "project_id", "type": "string", "nullable": false }
3368 ],
3369 "scopes": [{ "pattern": "project:{project_id}" }]
3370 },
3371 {
3372 "name": "legacy",
3373 "primaryKey": "id",
3374 "columns": [
3375 { "name": "id", "type": "string", "nullable": false },
3376 { "name": "project_id", "type": "string", "nullable": false }
3377 ],
3378 "scopes": [{ "pattern": "project:{project_id}" }]
3379 }
3380 ]
3381 });
3382 {
3383 let mut first = SyncClient::open_path_with_identity(
3384 None,
3385 &old_schema,
3386 ClientLimits::default(),
3387 path.to_str().expect("UTF-8 temp path"),
3388 )
3389 .expect("open old schema");
3390 first
3391 .subscribe(
3392 "legacy-sub".to_owned(),
3393 "legacy".to_owned(),
3394 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3395 None,
3396 )
3397 .expect("persist legacy subscription");
3398 }
3399
3400 let new_schema = json!({
3401 "version": 2,
3402 "tables": [{
3403 "name": "tasks",
3404 "primaryKey": "id",
3405 "columns": [
3406 { "name": "id", "type": "string", "nullable": false },
3407 { "name": "project_id", "type": "string", "nullable": false },
3408 { "name": "facility_membership_id", "type": "string", "nullable": true }
3409 ],
3410 "scopes": [{ "pattern": "project:{project_id}" }],
3411 "indexes": [{
3412 "name": "tasks_by_membership",
3413 "columns": ["project_id", "facility_membership_id"],
3414 "unique": false
3415 }]
3416 }]
3417 });
3418 let upgraded = SyncClient::open_path_with_identity(
3419 None,
3420 &new_schema,
3421 ClientLimits::default(),
3422 path.to_str().expect("UTF-8 temp path"),
3423 )
3424 .expect("open upgraded schema");
3425 let columns = upgraded
3426 .conn
3427 .prepare("PRAGMA table_info(tasks)")
3428 .expect("prepare columns")
3429 .query_map([], |row| row.get::<_, String>(1))
3430 .expect("query columns")
3431 .collect::<Result<Vec<_>, _>>()
3432 .expect("collect columns");
3433 assert!(columns.contains(&"facility_membership_id".to_owned()));
3434 let index_count: i64 = upgraded
3435 .conn
3436 .query_row(
3437 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3438 [],
3439 |row| row.get(0),
3440 )
3441 .expect("query index");
3442 assert_eq!(index_count, 1);
3443 assert!(upgraded.subscription_state("legacy-sub").is_none());
3444 drop(upgraded);
3445 std::fs::remove_file(path).expect("remove temp database");
3446 }
3447
3448 #[test]
3449 fn migrates_pre_envelope_outcome_journal_additively() {
3450 let path = std::env::temp_dir().join(format!(
3451 "syncular-outcome-migration-{}.db",
3452 uuid::Uuid::new_v4()
3453 ));
3454 let conn = Connection::open(&path).expect("open legacy database");
3455 conn.execute_batch(
3456 "CREATE TABLE _syncular_commit_outcomes (
3457 seq INTEGER PRIMARY KEY AUTOINCREMENT,
3458 client_commit_id TEXT NOT NULL UNIQUE,
3459 status TEXT NOT NULL,
3460 recorded_at_ms INTEGER NOT NULL,
3461 results_json TEXT NOT NULL,
3462 resolution TEXT NOT NULL DEFAULT 'active',
3463 resolved_at_ms INTEGER,
3464 replacement_client_commit_id TEXT);",
3465 )
3466 .expect("create legacy outcome journal");
3467 drop(conn);
3468 let schema = json!({
3469 "version": 1,
3470 "tables": [{
3471 "name": "tasks",
3472 "primaryKey": "id",
3473 "columns": [
3474 { "name": "id", "type": "string", "nullable": false },
3475 { "name": "project_id", "type": "string", "nullable": false }
3476 ],
3477 "scopes": [{ "pattern": "project:{project_id}" }]
3478 }]
3479 });
3480 let client = SyncClient::open_path(
3481 "migration-native".to_owned(),
3482 &schema,
3483 ClientLimits::default(),
3484 path.to_str().expect("UTF-8 temp path"),
3485 )
3486 .expect("migrate database");
3487 let has_operations = client
3488 .conn
3489 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3490 .expect("prepare table info")
3491 .query_map([], |row| row.get::<_, String>(1))
3492 .expect("query table info")
3493 .filter_map(Result::ok)
3494 .any(|column| column == "operations_json");
3495 assert!(has_operations);
3496 drop(client);
3497 std::fs::remove_file(path).expect("remove temp database");
3498 }
3499
3500 #[test]
3501 fn durable_conflict_outcome_and_resolution_survive_reopen() {
3502 let path = std::env::temp_dir().join(format!(
3503 "syncular-durable-outcome-{}.db",
3504 uuid::Uuid::new_v4()
3505 ));
3506 let schema = json!({
3507 "version": 1,
3508 "tables": [{
3509 "name": "tasks",
3510 "primaryKey": "id",
3511 "columns": [
3512 { "name": "id", "type": "string", "nullable": false },
3513 { "name": "project_id", "type": "string", "nullable": false }
3514 ],
3515 "scopes": [{ "pattern": "project:{project_id}" }]
3516 }]
3517 });
3518 let conflict = ConflictRecord {
3519 client_commit_id: "losing-commit".to_owned(),
3520 op_index: 0,
3521 table: "tasks".to_owned(),
3522 row_id: "t1".to_owned(),
3523 code: "sync.version_conflict".to_owned(),
3524 message: "stale base version".to_owned(),
3525 server_version: 2,
3526 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3527 conflict_columns: vec!["project_id".to_owned()],
3528 operation: Some(CommitOperation {
3529 table: "tasks".to_owned(),
3530 row_id: "t1".to_owned(),
3531 op: "upsert".to_owned(),
3532 base_version: Some(1),
3533 values: None,
3534 }),
3535 };
3536 let failed_operations = vec![
3537 OutboxOp {
3538 upsert: true,
3539 table: "tasks".to_owned(),
3540 row_id: "t1".to_owned(),
3541 base_version: Some(1),
3542 values: None,
3543 },
3544 OutboxOp {
3545 upsert: true,
3546 table: "tasks".to_owned(),
3547 row_id: "status-event-1".to_owned(),
3548 base_version: Some(0),
3549 values: None,
3550 },
3551 ];
3552
3553 {
3554 let mut first = SyncClient::open_path(
3555 "durable-native".to_owned(),
3556 &schema,
3557 ClientLimits::default(),
3558 path.to_str().expect("UTF-8 temp path"),
3559 )
3560 .expect("first open");
3561 first
3562 .begin_observation("test_outcome")
3563 .expect("begin outcome");
3564 first
3565 .persist_commit_outcome(
3566 "losing-commit",
3567 CommitOutcomeStatus::Conflict,
3568 &[CommitOperationOutcome::Conflict {
3569 conflict: conflict.clone(),
3570 }],
3571 Some(&failed_operations),
3572 )
3573 .expect("persist outcome");
3574 first.conflicts.push(conflict);
3575 first
3576 .finish_observation(
3577 "test_outcome",
3578 ChangeAccumulator {
3579 conflicts: true,
3580 outcomes: true,
3581 ..ChangeAccumulator::default()
3582 },
3583 )
3584 .expect("commit outcome");
3585 }
3586
3587 {
3588 let mut reopened = SyncClient::open_path(
3589 "durable-native".to_owned(),
3590 &schema,
3591 ClientLimits::default(),
3592 path.to_str().expect("UTF-8 temp path"),
3593 )
3594 .expect("reopen");
3595 assert_eq!(reopened.conflicts().len(), 1);
3596 assert_eq!(
3597 reopened.conflicts()[0].conflict_columns,
3598 vec!["project_id".to_owned()],
3599 "conflictColumns survives the journal round trip"
3600 );
3601 let outcome = reopened
3602 .commit_outcome("losing-commit")
3603 .expect("read outcome")
3604 .expect("outcome");
3605 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3606 let operations = outcome.operations.expect("aggregate envelope");
3607 assert_eq!(operations.len(), 2);
3608 assert_eq!(operations[1].row_id, "status-event-1");
3609 let resolved = reopened
3610 .resolve_commit_outcome(ResolveCommitOutcomeInput {
3611 client_commit_id: "losing-commit".to_owned(),
3612 resolution: CommitOutcomeResolution::ResolvedKeepServer,
3613 replacement_client_commit_id: None,
3614 })
3615 .expect("resolve");
3616 assert_eq!(
3617 resolved.resolution,
3618 CommitOutcomeResolution::ResolvedKeepServer
3619 );
3620 assert!(reopened.conflicts().is_empty());
3621 }
3622
3623 let reopened = SyncClient::open_path(
3624 "durable-native".to_owned(),
3625 &schema,
3626 ClientLimits::default(),
3627 path.to_str().expect("UTF-8 temp path"),
3628 )
3629 .expect("second reopen");
3630 assert!(reopened.conflicts().is_empty());
3631 assert_eq!(
3632 reopened
3633 .commit_outcome("losing-commit")
3634 .expect("read outcome")
3635 .expect("outcome")
3636 .resolution,
3637 CommitOutcomeResolution::ResolvedKeepServer
3638 );
3639 drop(reopened);
3640 std::fs::remove_file(path).expect("remove temp database");
3641 }
3642
3643 #[test]
3644 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3645 let path =
3646 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3647 let schema = json!({
3648 "version": 1,
3649 "tables": [{
3650 "name": "tasks",
3651 "primaryKey": "id",
3652 "columns": [
3653 { "name": "id", "type": "string", "nullable": false },
3654 { "name": "project_id", "type": "string", "nullable": false }
3655 ],
3656 "scopes": [{ "pattern": "project:{project_id}" }]
3657 }]
3658 });
3659 let mut client = SyncClient::open_path_with_identity(
3660 Some("sidecar-client".to_owned()),
3661 &schema,
3662 ClientLimits::default(),
3663 path.to_str().expect("UTF-8 temp path"),
3664 )
3665 .expect("open owner");
3666 let base = WindowBase {
3667 table: "tasks".to_owned(),
3668 variable: "project_id".to_owned(),
3669 fixed_scopes: Vec::new(),
3670 params: None,
3671 };
3672 client
3673 .set_window(&base, &["one".to_owned()])
3674 .expect("set window");
3675 client
3676 .mutate(vec![Mutation::Upsert {
3677 table: "tasks".to_owned(),
3678 values: Map::from_iter([
3679 ("id".to_owned(), Value::from("t1")),
3680 ("project_id".to_owned(), Value::from("one")),
3681 ]),
3682 base_version: None,
3683 }])
3684 .expect("local mutate");
3685
3686 let coverage = [WindowCoverage {
3687 base,
3688 units: vec!["one".to_owned(), "missing".to_owned()],
3689 }];
3690 let owner = client
3691 .query_snapshot(
3692 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3693 &[],
3694 &coverage,
3695 None,
3696 )
3697 .expect("owner snapshot");
3698 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3699 let sidecar = reader
3700 .query_snapshot(
3701 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3702 &[],
3703 &coverage,
3704 )
3705 .expect("sidecar snapshot");
3706
3707 assert_eq!(sidecar.revision, owner.revision);
3708 assert_eq!(sidecar.rows, owner.rows);
3709 assert_eq!(
3710 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3711 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3712 );
3713 assert_eq!(sidecar.revision, "2");
3714 assert_eq!(sidecar.rows[0]["id"], "t1");
3715 assert_eq!(sidecar.rows[0]["server_version"], -1);
3716 assert!(!sidecar.coverage.complete);
3717 assert_eq!(sidecar.coverage.pending.len(), 1);
3718 assert_eq!(sidecar.coverage.missing.len(), 1);
3719
3720 drop(reader);
3721 drop(client);
3722 std::fs::remove_file(path).expect("remove temp database");
3723 }
3724
3725 #[test]
3726 fn owned_query_failures_classify_order_bound_and_clear() {
3727 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3728 rusqlite::ffi::Error::new(266),
3729 Some("private SQLite prose".to_owned()),
3730 ));
3731 assert_eq!(io.code, Some("client.storage_io"));
3732 assert_eq!(io.sqlite_code, Some(266));
3733 assert_eq!(io.message, "local SQLite storage I/O failed");
3734 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3735 rusqlite::ffi::Error::new(26),
3736 None,
3737 ));
3738 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
3739
3740 let mut client = client();
3741 let generic = QueryReadFailure::from("private query prose".to_owned());
3742 client.record_query_read(
3743 QueryOwner {
3744 id: "first",
3745 tables: &["tasks", "tasks"],
3746 },
3747 Some(&generic),
3748 );
3749 client.record_query_read(
3750 QueryOwner {
3751 id: "second",
3752 tables: &["tasks"],
3753 },
3754 Some(&generic),
3755 );
3756 client.record_query_read(
3757 QueryOwner {
3758 id: "first",
3759 tables: &["docs"],
3760 },
3761 Some(&io),
3762 );
3763 let failures = &client
3764 .diagnostics_snapshot(&Default::default())
3765 .expect("diagnostics")
3766 .query_failures;
3767 assert_eq!(
3768 failures
3769 .iter()
3770 .map(|entry| entry.id.as_str())
3771 .collect::<Vec<_>>(),
3772 vec!["second", "first"]
3773 );
3774 assert_eq!(failures[1].tables, vec!["docs"]);
3775 assert_eq!(failures[1].code, "client.storage_io");
3776
3777 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
3778 client.record_query_read(
3779 QueryOwner {
3780 id: &format!("query-{index}"),
3781 tables: &["tasks"],
3782 },
3783 Some(&generic),
3784 );
3785 }
3786 let failures = &client
3787 .diagnostics_snapshot(&Default::default())
3788 .expect("bounded diagnostics")
3789 .query_failures;
3790 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
3791 assert_eq!(failures.last().expect("last failure").id, "query-256");
3792
3793 client
3794 .query_snapshot(
3795 "SELECT id FROM tasks",
3796 &[],
3797 &[],
3798 Some(QueryOwner {
3799 id: "query-256",
3800 tables: &["tasks"],
3801 }),
3802 )
3803 .expect("successful owned read");
3804 assert!(client
3805 .diagnostics_snapshot(&Default::default())
3806 .expect("cleared diagnostics")
3807 .query_failures
3808 .iter()
3809 .all(|entry| entry.id != "query-256"));
3810 }
3811
3812 #[test]
3813 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3814 let mut client = client();
3815 let image = Connection::open_in_memory().unwrap();
3816 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3817 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3818 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3819 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3820 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3821 let table = client.schema.table("tasks").unwrap().clone();
3822 let scopes = vec![("project_id".into(), vec!["p1".into()])];
3823 assert!(client
3824 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3825 .is_err());
3826 assert!(client.conn.is_autocommit());
3827 assert_eq!(
3828 client
3829 .query("SELECT count(*) AS n FROM tasks", &[])
3830 .unwrap()[0]["n"],
3831 1024
3832 );
3833 assert_eq!(client.drain_change_batches().len(), 1);
3834 image
3835 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3836 .unwrap();
3837 assert!(matches!(
3838 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3839 Ok(2050)
3840 ));
3841 assert_eq!(
3842 client
3843 .query("SELECT count(*) AS n FROM tasks", &[])
3844 .unwrap()[0]["n"],
3845 2050
3846 );
3847 assert_eq!(client.drain_change_batches().len(), 3);
3848 }
3849
3850 #[test]
3851 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3852 let mut client = client();
3853 let base = WindowBase {
3854 table: "tasks".into(),
3855 variable: "project_id".into(),
3856 fixed_scopes: Vec::new(),
3857 params: None,
3858 };
3859 client
3860 .set_window(&base, &["p1".into(), "p2".into()])
3861 .unwrap();
3862 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3863 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3864 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3865 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3866 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3867 client.drain_change_batches();
3868 assert!(client.set_window(&base, &["p2".into()]).is_err());
3869 assert!(client.conn.is_autocommit());
3870 assert_eq!(
3871 client
3872 .query("SELECT count(*) AS n FROM tasks", &[])
3873 .unwrap()[0]["n"],
3874 1027
3875 );
3876 assert_eq!(client.window_state(&base).units, vec!["p2"]);
3877 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
3878 assert_eq!(client.drain_change_batches().len(), 1);
3879 client
3880 .conn
3881 .execute_batch("DROP TRIGGER interrupt_evict")
3882 .unwrap();
3883 client.drain_pending_evictions().unwrap();
3884 assert!(client.load_pending_evictions().unwrap().is_empty());
3885 assert_eq!(
3886 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3887 "held"
3888 );
3889 assert_eq!(client.drain_change_batches().len(), 2);
3890 }
3891
3892 #[test]
3893 fn eviction_marker_failures_roll_back_window_changes() {
3894 let mut client = client();
3895 let base = WindowBase {
3896 table: "tasks".into(),
3897 variable: "project_id".into(),
3898 fixed_scopes: Vec::new(),
3899 params: None,
3900 };
3901 client.set_window(&base, &["p1".into()]).unwrap();
3902 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3903 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3904 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3905 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3906 let revision = client.local_revision();
3907 assert!(client.set_window(&base, &[]).is_err());
3908 assert!(client.conn.is_autocommit());
3909 assert_eq!(client.local_revision(), revision);
3910 assert_eq!(
3911 client
3912 .query("SELECT count(*) AS n FROM tasks", &[])
3913 .unwrap()[0]["n"],
3914 2050
3915 );
3916 assert_eq!(client.window_state(&base).units, vec!["p1"]);
3917 client.conn.execute_batch("DROP TRIGGER fail_marker;
3918 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3919 assert!(client.set_window(&base, &[]).is_err());
3920 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
3921 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3922 assert!(client.drain_pending_evictions().is_err());
3923 assert!(client.conn.is_autocommit());
3924 assert_eq!(
3925 client
3926 .query("SELECT count(*) AS n FROM tasks", &[])
3927 .unwrap()[0]["n"],
3928 2
3929 );
3930 assert!(client.set_window(&base, &["p1".into()]).is_err());
3931 assert!(client.conn.is_autocommit());
3932 assert!(client.window_state(&base).units.is_empty());
3933 assert!(client.subs.is_empty());
3934 assert_eq!(
3935 client
3936 .query(
3937 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
3938 &[]
3939 )
3940 .unwrap()[0]["n"],
3941 1
3942 );
3943 client
3944 .conn
3945 .execute_batch("DROP TRIGGER fail_marker")
3946 .unwrap();
3947 client.set_window(&base, &["p1".into()]).unwrap();
3948 assert_eq!(client.window_state(&base).units, vec!["p1"]);
3949 }
3950
3951 #[test]
3952 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
3953 let mut client = client();
3954 client
3955 .conn
3956 .execute(
3957 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
3958 [],
3959 )
3960 .unwrap();
3961 assert_eq!(
3962 client.drain_pending_evictions().unwrap_err(),
3963 "sync.local_corrupt: invalid pending eviction scopes"
3964 );
3965 assert!(client.conn.is_autocommit());
3966 assert!(client.enqueue_startup_sync_if_needed().is_err());
3967 }
3968
3969 #[test]
3970 fn image_chunks_reject_null_primary_keys() {
3971 let mut client = client();
3972 let image = Connection::open_in_memory().unwrap();
3973 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3974 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
3975 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
3976 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3977 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
3978 let table = client.schema.table("tasks").unwrap().clone();
3979 assert!(client
3980 .apply_sqlite_image(
3981 &image,
3982 &table,
3983 true,
3984 &[("project_id".into(), vec!["p1".into()])],
3985 1025,
3986 7,
3987 "digest"
3988 )
3989 .is_err());
3990 assert!(client.conn.is_autocommit());
3991 assert_eq!(
3992 client
3993 .query("SELECT count(*) AS n FROM tasks", &[])
3994 .unwrap()[0]["n"],
3995 0
3996 );
3997 }
3998
3999 #[test]
4000 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
4001 for images in [false, true] {
4002 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4003 "name":name,"primaryKey":"id","columns":[
4004 {"name":"id","type":"string","nullable":false},
4005 {"name":"project_id","type":"string","nullable":false},
4006 {"name":"title","type":"string","nullable":false}],
4007 "scopes":[{"pattern":"project:{project_id}"}],
4008 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
4009 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4010 })))});
4011 let mut actual =
4012 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
4013 let mut reference =
4014 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
4015 let scopes = vec![("project_id".into(), vec!["p1".into()])];
4016 for client in [&mut actual, &mut reference] {
4017 client
4018 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
4019 .unwrap();
4020 client.subs[0].effective = Some(scopes.clone());
4021 for (table, id, title) in [
4022 ("tasks", "a", "original"),
4023 ("tasks", "b", "occupied"),
4024 ("tasks", "c", "deleted"),
4025 ("notes", "n", "untouched"),
4026 ] {
4027 client
4028 .write_base_row(
4029 table,
4030 &vec![
4031 Some(ColumnValue::String(id.into())),
4032 Some(ColumnValue::String("p1".into())),
4033 Some(ColumnValue::String(title.into())),
4034 ],
4035 1,
4036 )
4037 .unwrap();
4038 }
4039 client.rebuild_overlay_if_dirty();
4040 client
4041 .mutate(vec![
4042 Mutation::Upsert {
4043 table: "tasks".into(),
4044 values: Map::from_iter([
4045 ("id".into(), Value::from("a")),
4046 ("project_id".into(), Value::from("p1")),
4047 ("title".into(), Value::from("available")),
4048 ]),
4049 base_version: None,
4050 },
4051 Mutation::Delete {
4052 table: "tasks".into(),
4053 row_id: "c".into(),
4054 base_version: None,
4055 },
4056 Mutation::Upsert {
4057 table: "tasks".into(),
4058 values: Map::from_iter([
4059 ("id".into(), Value::from("local")),
4060 ("project_id".into(), Value::from("p1")),
4061 ("title".into(), Value::from("local value")),
4062 ]),
4063 base_version: None,
4064 },
4065 Mutation::Upsert {
4066 table: "notes".into(),
4067 values: Map::from_iter([
4068 ("id".into(), Value::from("n")),
4069 ("project_id".into(), Value::from("p1")),
4070 ("title".into(), Value::from("pending note")),
4071 ]),
4072 base_version: None,
4073 },
4074 ])
4075 .unwrap();
4076 }
4077 let pending = actual.pending_commit_ids();
4078 actual.overlay_rebuild_count.set(0);
4079 for (id, title) in [
4080 ("b", "available"),
4081 ("b", "occupied"),
4082 ("a", "server original"),
4083 ("d", "local value"),
4084 ("d", "released"),
4085 ("c", "redelivered"),
4086 ] {
4087 let table = actual.schema.table("tasks").unwrap().clone();
4088 let values = vec![
4089 Some(ColumnValue::String(id.into())),
4090 Some(ColumnValue::String("p1".into())),
4091 Some(ColumnValue::String(title.into())),
4092 ];
4093 if images {
4094 let image = Connection::open_in_memory().unwrap();
4095 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4096 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4097 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4098 image
4099 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
4100 .unwrap();
4101 assert!(matches!(
4102 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
4103 Ok(1)
4104 ));
4105 } else {
4106 let segment = RowsSegment {
4107 table: "tasks".into(),
4108 schema_version: 1,
4109 columns: table.wire_columns.clone(),
4110 blocks: vec![vec![ssp2::segment::SegmentRow {
4111 server_version: 2,
4112 values: values.clone(),
4113 }]],
4114 };
4115 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4116 }
4117 reference.write_base_row("tasks", &values, 2).unwrap();
4118 reference.rebuild_overlay();
4119 for table in ["tasks", "notes"] {
4120 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")] {
4121 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4122 }
4123 }
4124 assert_eq!(actual.pending_commit_ids(), pending);
4125 }
4126 assert_eq!(
4127 actual.overlay_rebuild_count.get(),
4128 0,
4129 "each chunk must leave unrelated tables alone"
4130 );
4131 let before = actual
4132 .query("SELECT * FROM tasks ORDER BY id", &[])
4133 .unwrap();
4134 let fts_before = actual
4135 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4136 .unwrap();
4137 let revision = actual.local_revision();
4138 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4139 let table = actual.schema.table("tasks").unwrap().clone();
4140 let segment = RowsSegment {
4141 table: "tasks".into(),
4142 schema_version: 1,
4143 columns: table.wire_columns.clone(),
4144 blocks: vec![vec![ssp2::segment::SegmentRow {
4145 server_version: 3,
4146 values: vec![
4147 Some(ColumnValue::String("b".into())),
4148 Some(ColumnValue::String("p1".into())),
4149 Some(ColumnValue::String("fresh".into())),
4150 ],
4151 }]],
4152 };
4153 assert!(actual.apply_segment(0, &segment, false).is_err());
4154 assert!(actual.conn.is_autocommit());
4155 assert_eq!(
4156 actual
4157 .query("SELECT * FROM tasks ORDER BY id", &[])
4158 .unwrap(),
4159 before
4160 );
4161 assert_eq!(
4162 actual
4163 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4164 .unwrap(),
4165 fts_before
4166 );
4167 assert_eq!(actual.local_revision(), revision);
4168 assert_eq!(actual.pending_commit_ids(), pending);
4169 }
4170 }
4171
4172 #[test]
4173 fn import_reconciliation_preserves_typed_pending_primary_keys() {
4174 for (kind, a, b) in [
4175 ("string", json!("a"), json!("b")),
4176 ("integer", json!(1), json!(2)),
4177 ("boolean", json!(true), json!(false)),
4178 ("json", json!("1"), json!("2")),
4179 ] {
4180 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}]}]});
4181 for images in [false, true] {
4182 let mut client =
4183 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
4184 let table = client.schema.table("tasks").unwrap().clone();
4185 client
4186 .subscribe("tasks".into(), "tasks".into(), vec![], None)
4187 .unwrap();
4188 for (id, title) in [(&a, "original"), (&b, "occupied")] {
4189 client
4190 .write_base_row(
4191 "tasks",
4192 &vec![
4193 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
4194 Some(ColumnValue::String(title.into())),
4195 ],
4196 1,
4197 )
4198 .unwrap();
4199 }
4200 client.rebuild_overlay_if_dirty();
4201 client
4202 .mutate(vec![Mutation::Upsert {
4203 table: "tasks".into(),
4204 values: Map::from_iter([
4205 ("id".into(), a.clone()),
4206 ("title".into(), json!("available")),
4207 ]),
4208 base_version: None,
4209 }])
4210 .unwrap();
4211 client.overlay_rebuild_count.set(0);
4212 for (title, expected) in [("available", "original"), ("occupied", "available")] {
4213 let values = vec![
4214 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
4215 Some(ColumnValue::String(title.into())),
4216 ];
4217 if images {
4218 let image = Connection::open_in_memory().unwrap();
4219 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4220 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4221 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4222 image
4223 .execute(
4224 "INSERT INTO tasks VALUES (?1,?2,2)",
4225 rusqlite::params![RowParam::Cell(&values[0]), title],
4226 )
4227 .unwrap();
4228 assert!(
4229 matches!(
4230 client.apply_sqlite_image(
4231 &image,
4232 &table,
4233 false,
4234 &[],
4235 1,
4236 7,
4237 "digest"
4238 ),
4239 Ok(1)
4240 ),
4241 "{kind}, images={images}"
4242 );
4243 } else {
4244 let segment = RowsSegment {
4245 table: "tasks".into(),
4246 schema_version: 1,
4247 columns: table.wire_columns.clone(),
4248 blocks: vec![vec![ssp2::segment::SegmentRow {
4249 server_version: 2,
4250 values,
4251 }]],
4252 };
4253 assert!(
4254 matches!(client.apply_segment(0, &segment, false), Ok(1)),
4255 "{kind}"
4256 );
4257 }
4258 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
4259 let actual: String = client
4260 .conn
4261 .query_row(
4262 "SELECT title FROM tasks WHERE id=?1",
4263 [RowParam::Cell(&key)],
4264 |row| row.get(0),
4265 )
4266 .unwrap();
4267 assert_eq!(actual, expected, "{kind}, images={images}");
4268 }
4269 assert_eq!(client.overlay_rebuild_count.get(), 0);
4270 }
4271 }
4272 }
4273
4274 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
4275 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4276 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4277 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4278 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4279 })))});
4280 let mut client =
4281 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4282 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();
4283 client.rebuild_overlay();
4284 client
4285 .mutate(vec![Mutation::Upsert {
4286 table: "tasks".into(),
4287 values: Map::from_iter([
4288 ("id".into(), json!("offline")),
4289 ("project_id".into(), json!("p1")),
4290 ("title".into(), json!("pending edit")),
4291 ]),
4292 base_version: None,
4293 }])
4294 .unwrap();
4295 let image = Connection::open_in_memory().unwrap();
4296 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4297 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4298 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();
4299 image
4300 .execute(
4301 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4302 [rows],
4303 )
4304 .unwrap();
4305 client.overlay_rebuild_count.set(0);
4306 let table = client.schema.table("tasks").unwrap().clone();
4307 (client, image, table)
4308 }
4309
4310 #[test]
4311 fn large_unique_import_copies_no_unrelated_tables() {
4312 let (mut client, image, table) = unique_import_fixture(10240);
4313 let pending = client.pending_commit_ids();
4314 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4315 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4316 assert!(matches!(
4317 client.apply_sqlite_image(
4318 &image,
4319 &table,
4320 true,
4321 &[("project_id".into(), vec!["p1".into()])],
4322 10240,
4323 7,
4324 "digest"
4325 ),
4326 Ok(10240)
4327 ));
4328 assert_eq!(client.overlay_rebuild_count.get(), 0);
4329 assert_eq!(client.pending_commit_ids(), pending);
4330 assert_eq!(
4331 client
4332 .query("SELECT count(*) AS n FROM tasks", &[])
4333 .unwrap()[0]["n"],
4334 10241
4335 );
4336 assert_eq!(
4337 client
4338 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4339 .unwrap()[0]["n"],
4340 10241
4341 );
4342 assert_eq!(
4343 client
4344 .query("SELECT count(*) AS n FROM notes_fts", &[])
4345 .unwrap()[0]["n"],
4346 8192
4347 );
4348 }
4349
4350 #[test]
4351 #[ignore = "manual scaling benchmark; see bench/README.md"]
4352 fn benchmark_unique_image_import() {
4353 let mut samples = Vec::new();
4354 for rows in [10240, 51200, 102400] {
4355 for trial in 0..4 {
4356 let (mut client, image, table) = unique_import_fixture(rows);
4357 let start = std::time::Instant::now();
4358 assert!(
4359 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)
4360 );
4361 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4362 assert_eq!(
4363 client
4364 .query("SELECT count(*) AS n FROM tasks", &[])
4365 .unwrap()[0]["n"],
4366 rows + 1
4367 );
4368 assert_eq!(
4369 client
4370 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4371 .unwrap()[0]["n"],
4372 rows + 1
4373 );
4374 assert_eq!(
4375 client
4376 .query("SELECT count(*) AS n FROM notes_fts", &[])
4377 .unwrap()[0]["n"],
4378 8192
4379 );
4380 assert_eq!(client.pending_commit_ids().len(), 1);
4381 if trial > 0 {
4382 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4383 }
4384 }
4385 }
4386 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});
4387 let output = serde_json::to_string_pretty(&artifact).unwrap();
4388 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4389 use std::io::Write;
4390 let mut file = std::fs::OpenOptions::new()
4391 .write(true)
4392 .create_new(true)
4393 .open(path)
4394 .unwrap();
4395 file.write_all(output.as_bytes()).unwrap();
4396 } else {
4397 println!("{output}");
4398 }
4399 }
4400
4401 #[test]
4402 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4403 let schema = json!({
4404 "version": 1,
4405 "tables": [{
4406 "name": "catalogue_codes",
4407 "primaryKey": "id",
4408 "columns": [
4409 { "name": "id", "type": "string", "nullable": false },
4410 { "name": "release_id", "type": "string", "nullable": false },
4411 { "name": "code", "type": "string", "nullable": false },
4412 { "name": "title", "type": "string", "nullable": false }
4413 ],
4414 "scopes": [{ "pattern": "release:{release_id}" }],
4415 "ftsIndexes": [{
4416 "name": "catalogue_codes_fts",
4417 "columns": ["code", "title"],
4418 "tokenize": "unicode61 remove_diacritics 2"
4419 }]
4420 }]
4421 });
4422 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4423 .expect("FTS5 client");
4424 let insert_trigger: String = client
4425 .conn
4426 .query_row(
4427 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4428 [],
4429 |row| row.get(0),
4430 )
4431 .expect("insert trigger");
4432 let replace_guard: String = client
4433 .conn
4434 .query_row(
4435 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4436 [],
4437 |row| row.get(0),
4438 )
4439 .expect("replace guard");
4440 assert!(!insert_trigger.contains("DELETE FROM"));
4441 assert!(replace_guard.contains("BEFORE INSERT"));
4442 assert!(replace_guard.contains("WHEN EXISTS"));
4443 let search = |client: &SyncClient, query: &str| {
4444 client
4445 .query(
4446 "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",
4447 &[Value::from(query)],
4448 )
4449 .expect("FTS query")
4450 };
4451
4452 client
4453 .mutate(vec![Mutation::Upsert {
4454 table: "catalogue_codes".to_owned(),
4455 values: Map::from_iter([
4456 ("id".to_owned(), Value::from("c1")),
4457 ("release_id".to_owned(), Value::from("r1")),
4458 ("code".to_owned(), Value::from("A01")),
4459 ("title".to_owned(), Value::from("Cholera")),
4460 ]),
4461 base_version: None,
4462 }])
4463 .expect("insert code");
4464 assert_eq!(search(&client, "cholera").len(), 1);
4465 let prior = client
4466 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4467 .unwrap();
4468 client
4469 .conn
4470 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4471 .unwrap();
4472 client.create_synced_tables().unwrap();
4473 assert_eq!(
4474 client
4475 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4476 .unwrap(),
4477 prior
4478 );
4479 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();
4480 let rendered = serde_json::to_string(&plan).unwrap();
4481 assert!(rendered.contains("COVERING INDEX"));
4482 assert!(rendered.contains("INDEX 0:="));
4483 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4484 assert_eq!(search(&client, "cholera").len(), 1);
4485
4486 client
4487 .mutate(vec![Mutation::Upsert {
4488 table: "catalogue_codes".to_owned(),
4489 values: Map::from_iter([
4490 ("id".to_owned(), Value::from("c1")),
4491 ("release_id".to_owned(), Value::from("r1")),
4492 ("code".to_owned(), Value::from("A01")),
4493 ("title".to_owned(), Value::from("Enteric infection")),
4494 ]),
4495 base_version: None,
4496 }])
4497 .expect("update code");
4498 assert!(search(&client, "cholera").is_empty());
4499 assert_eq!(search(&client, "enteric").len(), 1);
4500
4501 client
4502 .mutate(vec![Mutation::Delete {
4503 table: "catalogue_codes".to_owned(),
4504 row_id: "c1".to_owned(),
4505 base_version: None,
4506 }])
4507 .expect("delete code");
4508 assert!(search(&client, "enteric").is_empty());
4509 }
4510
4511 #[test]
4512 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4513 let schema = json!({
4514 "version": 1,
4515 "tables": [{
4516 "name": "patient_notes",
4517 "primaryKey": "id",
4518 "columns": [
4519 { "name": "id", "type": "string", "nullable": false },
4520 { "name": "practice_id", "type": "string", "nullable": false },
4521 { "name": "encryption_key_id", "type": "string", "nullable": false },
4522 { "name": "title", "type": "string", "nullable": false }
4523 ],
4524 "scopes": [{ "pattern": "practice:{practice_id}" }],
4525 "ftsIndexes": [{
4526 "name": "patient_notes_fts",
4527 "columns": ["title"],
4528 "tokenize": "unicode61 remove_diacritics 2"
4529 }]
4530 }]
4531 });
4532 let mut client = SyncClient::new(
4533 "local-purge-test".to_owned(),
4534 &schema,
4535 ClientLimits::default(),
4536 )
4537 .expect("local purge client");
4538 client
4539 .conn
4540 .execute(
4541 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4542 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4543 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4544 [],
4545 )
4546 .expect("seed base rows");
4547 client.overlay_dirty.set(true);
4548 client.rebuild_overlay_if_dirty();
4549
4550 let note = |id: &str, key_id: &str, title: &str| {
4551 Map::from_iter([
4552 ("id".to_owned(), Value::from(id)),
4553 ("practice_id".to_owned(), Value::from("practice-1")),
4554 ("encryption_key_id".to_owned(), Value::from(key_id)),
4555 ("title".to_owned(), Value::from(title)),
4556 ])
4557 };
4558 let doomed = client
4559 .mutate(vec![
4560 Mutation::Upsert {
4561 table: "patient_notes".to_owned(),
4562 values: note("target", "key-revoked", "Target changed"),
4563 base_version: None,
4564 },
4565 Mutation::Upsert {
4566 table: "patient_notes".to_owned(),
4567 values: note("unrelated", "key-held", "Unrelated changed"),
4568 base_version: None,
4569 },
4570 ])
4571 .expect("doomed commit");
4572 let kept = client
4573 .mutate(vec![Mutation::Upsert {
4574 table: "patient_notes".to_owned(),
4575 values: note("kept", "key-held", "Kept optimistic"),
4576 base_version: None,
4577 }])
4578 .expect("kept commit");
4579 client.drain_change_batches();
4580
4581 let input = LocalDataPurgeInput {
4582 purge_id: "purge-001".to_owned(),
4583 targets: vec![LocalDataPurgeTarget {
4584 table: "patient_notes".to_owned(),
4585 selectors: BTreeMap::from([(
4586 "encryption_key_id".to_owned(),
4587 vec!["key-revoked".to_owned()],
4588 )]),
4589 }],
4590 };
4591 assert_eq!(
4592 client.purge_local_data(&input).expect("apply purge"),
4593 LocalDataPurgeResult {
4594 already_applied: false,
4595 purged_rows: 1,
4596 dropped_commits: 1,
4597 }
4598 );
4599 let rows = client
4600 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
4601 .expect("visible rows");
4602 assert_eq!(rows.len(), 2);
4603 assert_eq!(rows[0]["id"], "kept");
4604 assert_eq!(rows[1]["id"], "unrelated");
4605 assert_eq!(rows[1]["title"], "Unrelated original");
4606 let fts = client
4607 .query(
4608 "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'",
4609 &[],
4610 )
4611 .expect("fts query");
4612 assert!(fts.is_empty());
4613 assert_eq!(client.outbox.len(), 1);
4614 assert_eq!(client.outbox[0].client_commit_id, kept);
4615 let outcome = client
4616 .commit_outcome(&doomed)
4617 .expect("read doomed outcome")
4618 .expect("doomed outcome");
4619 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
4620 match &outcome.results[0] {
4621 CommitOperationOutcome::Error { rejection } => {
4622 assert_eq!(rejection.code, "client.local_data_purged");
4623 assert_eq!(rejection.client_commit_id, doomed);
4624 }
4625 other => panic!("expected local purge rejection, got {other:?}"),
4626 }
4627 assert_eq!(client.drain_change_batches().len(), 1);
4628 assert_eq!(
4629 client.purge_local_data(&input).expect("retry purge"),
4630 LocalDataPurgeResult {
4631 already_applied: true,
4632 purged_rows: 0,
4633 dropped_commits: 0,
4634 }
4635 );
4636 let conflicting = LocalDataPurgeInput {
4637 purge_id: input.purge_id.clone(),
4638 targets: vec![LocalDataPurgeTarget {
4639 table: "patient_notes".to_owned(),
4640 selectors: BTreeMap::from([(
4641 "encryption_key_id".to_owned(),
4642 vec!["key-held".to_owned()],
4643 )]),
4644 }],
4645 };
4646 assert!(client
4647 .purge_local_data(&conflicting)
4648 .expect_err("id collision must fail")
4649 .contains("already used with a different plan"));
4650 }
4651}
4652
4653impl SubState {
4654 fn name(self) -> &'static str {
4655 match self {
4656 SubState::Active => "active",
4657 SubState::Revoked => "revoked",
4658 SubState::Failed => "failed",
4659 }
4660 }
4661
4662 fn parse(value: &str) -> Self {
4663 match value {
4664 "revoked" => Self::Revoked,
4665 "failed" => Self::Failed,
4666 _ => Self::Active,
4667 }
4668 }
4669}
4670
4671#[derive(Debug, Clone)]
4672struct Subscription {
4673 id: String,
4674 table: String,
4675 requested: Vec<(String, Vec<String>)>,
4676 params: Option<String>,
4677 cursor: i64,
4678 bootstrap_state: Option<String>,
4680 state: SubState,
4681 reason_code: Option<String>,
4682 effective: Option<Vec<(String, Vec<String>)>>,
4685 synced_once: bool,
4686}
4687
4688#[derive(Debug, Clone)]
4689struct OutboxOp {
4690 upsert: bool,
4691 table: String,
4692 row_id: String,
4693 base_version: Option<i64>,
4694 values: Option<Map<String, Value>>,
4698}
4699
4700impl From<&OutboxOp> for CommitOperation {
4701 fn from(operation: &OutboxOp) -> Self {
4702 Self {
4703 table: operation.table.clone(),
4704 row_id: operation.row_id.clone(),
4705 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4706 base_version: operation.base_version,
4707 values: operation.values.clone(),
4708 }
4709 }
4710}
4711
4712#[derive(Debug, Clone)]
4713struct OutboxCommit {
4714 client_commit_id: String,
4715 ops: Vec<OutboxOp>,
4716}
4717
4718fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
4722 commit
4723 .ops
4724 .iter()
4725 .filter(|operation| operation.upsert)
4726 .map(|operation| {
4727 (
4728 operation.table.as_str(),
4729 operation
4730 .values
4731 .as_ref()
4732 .map(|values| values.keys().map(String::as_str).collect())
4733 .unwrap_or_default(),
4734 )
4735 })
4736 .collect()
4737}
4738
4739#[derive(Debug, Clone)]
4740struct CompiledLocalDataPurgeTarget {
4741 table: String,
4742 selectors: Vec<(String, Vec<String>)>,
4743}
4744
4745struct StoredCommitOutcomeRow {
4746 sequence: i64,
4747 client_commit_id: String,
4748 status: String,
4749 recorded_at_ms: i64,
4750 results_json: String,
4751 operations_json: Option<String>,
4752 resolution: String,
4753 resolved_at_ms: Option<i64>,
4754 replacement_client_commit_id: Option<String>,
4755}
4756
4757enum SectionError {
4760 FailClosed,
4761 Abort(String, String),
4762}
4763
4764struct RequestMeta {
4765 pushed_ids: Vec<String>,
4766 fresh: Vec<(String, bool)>,
4769 accept: u8,
4770 deferred_commits: usize,
4774}
4775
4776#[derive(Default)]
4777struct ChangeAccumulator {
4778 tables: BTreeMap<String, Option<BTreeSet<String>>>,
4780 windows: BTreeMap<(String, String), BTreeSet<String>>,
4781 status: bool,
4782 conflicts: bool,
4783 rejections: bool,
4784 outcomes: bool,
4785}
4786
4787impl ChangeAccumulator {
4788 fn table(&mut self, table: &str) {
4789 self.tables.insert(table.to_owned(), None);
4790 }
4791
4792 fn scope(&mut self, table: &str, key: String) {
4793 match self.tables.get_mut(table) {
4794 Some(None) => {}
4795 Some(Some(keys)) => {
4796 keys.insert(key);
4797 }
4798 None => {
4799 self.tables
4800 .insert(table.to_owned(), Some(BTreeSet::from([key])));
4801 }
4802 }
4803 }
4804
4805 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4806 self.windows
4807 .entry((base_key.to_owned(), table.to_owned()))
4808 .or_default()
4809 .insert(unit.to_owned());
4810 }
4811
4812 fn touched(&self) -> bool {
4813 !self.tables.is_empty()
4814 || !self.windows.is_empty()
4815 || self.status
4816 || self.conflicts
4817 || self.rejections
4818 || self.outcomes
4819 }
4820}
4821
4822const PUSH_OPS_PER_REQUEST: usize = 500;
4828const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4829const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4830const MAX_LOCAL_PURGE_VALUES: usize = 128;
4831const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4832pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4833
4834pub struct SyncClient {
4835 #[cfg(feature = "bench-internals")]
4836 benchmark_phases: Recorder,
4837 conn: Connection,
4838 schema: ClientSchema,
4839 client_id: String,
4840 limits: ClientLimits,
4841 subs: Vec<Subscription>,
4842 outbox: Vec<OutboxCommit>,
4843 conflicts: Vec<ConflictRecord>,
4844 rejections: Vec<RejectionRecord>,
4845 schema_floor: Option<SchemaFloor>,
4846 lease_state: Option<LeaseState>,
4848 stopped: bool,
4850 upgrading: bool,
4852 sync_needed: bool,
4854 realtime_policy: RealtimePolicy,
4857 realtime_state: RealtimeState,
4858 realtime_reason_code: Option<String>,
4859 realtime_retry_delay_ms: Option<u64>,
4860 presence: HashMap<String, HashMap<String, PresencePeer>>,
4862 now_ms: Option<i64>,
4865 encryption: crate::values::EncryptionConfig,
4869 security_preflight: bool,
4872 previous_version: Option<PreviousVersionContextConfig>,
4876 insert_sql: RefCell<HashMap<String, String>>,
4882 overlay_dirty: Cell<bool>,
4886 #[cfg(test)]
4889 overlay_rebuild_count: Cell<usize>,
4890 #[cfg(test)]
4893 outcome_prune_count: Cell<usize>,
4894 progress: ProgressObserver,
4896 change_queue: VecDeque<ClientChangeBatch>,
4897 sync_intent_queue: VecDeque<SyncIntent>,
4898 retry_delay_ms: u64,
4900 round_retry_delay_ms: Option<u64>,
4902 last_round: Option<DiagnosticLastRound>,
4903 last_change: Option<DiagnosticLastChange>,
4904 query_failures: Vec<DiagnosticQueryFailure>,
4906}
4907
4908pub(crate) fn quote_ident(name: &str) -> String {
4909 format!("\"{}\"", name.replace('"', "\"\""))
4910}
4911pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
4915 conn.query_row(
4916 "SELECT value FROM _syncular_meta WHERE key = ?1",
4917 rusqlite::params![key],
4918 |row| row.get::<_, String>(0),
4919 )
4920 .ok()
4921}
4922
4923pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
4924 let _ = conn.execute(
4925 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
4926 rusqlite::params![key, value],
4927 );
4928}
4929
4930pub(crate) fn meta_delete(conn: &Connection, key: &str) {
4931 let _ = conn.execute(
4932 "DELETE FROM _syncular_meta WHERE key = ?1",
4933 rusqlite::params![key],
4934 );
4935}
4936
4937fn row_id_predicate(table: &TableSchema) -> String {
4939 let key = quote_ident(&table.primary_key);
4940 let text_match = format!("CAST({key} AS TEXT) = ?1");
4941 match table.columns[table.pk_index].ty {
4942 ColumnType::String | ColumnType::Json => {
4943 format!("{key} = ?1 AND {text_match}")
4944 }
4945 ColumnType::Integer | ColumnType::Boolean => {
4949 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
4950 }
4951 _ => text_match,
4954 }
4955}
4956
4957fn is_local_operation_code_like(value: &str) -> bool {
4958 let bytes = value.as_bytes();
4959 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
4960 && bytes
4961 .iter()
4962 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
4963}
4964
4965fn base_table(name: &str) -> String {
4966 quote_ident(&format!("_syncular_base_{name}"))
4967}
4968
4969type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
4974
4975fn window_base_key(base: &WindowBase) -> String {
4976 format!(
4977 "{}\0{}\0{}",
4978 base.table,
4979 base.variable,
4980 canonical_scope_json(&base.fixed_scopes)
4981 )
4982}
4983
4984fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
4986 let mut scopes = base.fixed_scopes.clone();
4987 scopes.retain(|(k, _)| k != &base.variable);
4988 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
4989 scopes
4990}
4991
4992fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
4996 let canonical = canonical_scope_json(&unit_scopes(base, unit));
4997 let digest = Sha256::digest(canonical.as_bytes());
4998 let hex = bytes_to_hex(&digest);
4999 format!("w:{}:{}", base.table, &hex[..16])
5000}
5001
5002fn visible_table(name: &str) -> String {
5003 quote_ident(name)
5004}
5005
5006const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
5007
5008fn is_synced_table_name(name: &str) -> bool {
5011 if name.starts_with("sqlite_") {
5012 return false;
5013 }
5014 if name.starts_with("_syncular_base_") {
5015 return true; }
5017 !name.starts_with("_syncular_")
5019}
5020
5021fn blob_id_for(bytes: &[u8]) -> String {
5023 let digest = Sha256::digest(bytes);
5024 format!("sha256:{}", bytes_to_hex(&digest))
5025}
5026
5027enum RowParam<'a> {
5031 Cell(&'a Option<ColumnValue>),
5032 Version(i64),
5033}
5034
5035impl rusqlite::ToSql for RowParam<'_> {
5036 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
5037 Ok(match self {
5038 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
5039 RowParam::Cell(cell) => match cell {
5040 None => ToSqlOutput::Owned(SqlValue::Null),
5041 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
5042 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
5043 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
5044 Some(ColumnValue::Boolean(b)) => {
5045 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
5046 }
5047 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
5048 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
5049 }
5050 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
5052 ToSqlOutput::Borrowed(ValueRef::Blob(b))
5053 }
5054 },
5055 })
5056 }
5057}
5058
5059fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
5061 match param.to_sql().map_err(|error| error.to_string())? {
5062 ToSqlOutput::Owned(value) => Ok(value),
5063 ToSqlOutput::Borrowed(value) => {
5064 SqlValue::try_from(value).map_err(|error| error.to_string())
5065 }
5066 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
5067 }
5068}
5069
5070fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
5078 use ssp2::segment::ColumnType;
5079 let mismatch = || {
5080 Err(format!(
5081 "image column {:?} holds a value of the wrong type",
5082 column.name
5083 ))
5084 };
5085 match value {
5086 ValueRef::Null => {
5087 if !column.nullable {
5088 return Err(format!(
5089 "image column {:?} is NULL but not nullable",
5090 column.name
5091 ));
5092 }
5093 Ok(ToSqlOutput::Owned(SqlValue::Null))
5094 }
5095 ValueRef::Integer(i) => match column.ty {
5096 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
5097 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
5098 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
5099 _ => mismatch(),
5100 },
5101 ValueRef::Real(_) => match column.ty {
5102 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
5103 _ => mismatch(),
5104 },
5105 ValueRef::Text(t) => {
5106 std::str::from_utf8(t)
5107 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
5108 match column.ty {
5109 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
5110 Ok(ToSqlOutput::Borrowed(value))
5111 }
5112 _ => mismatch(),
5113 }
5114 }
5115 ValueRef::Blob(_) => match column.ty {
5116 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
5118 _ => mismatch(),
5119 },
5120 }
5121}
5122
5123pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
5124 use rusqlite::types::ValueRef;
5125 match value {
5126 ValueRef::Null => Value::Null,
5127 ValueRef::Integer(i) => match column.ty {
5128 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
5129 ssp2::segment::ColumnType::Float => {
5130 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
5131 }
5132 _ => Value::from(i),
5133 },
5134 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5135 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5136 ValueRef::Blob(b) => {
5137 let mut map = Map::new();
5138 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5139 Value::Object(map)
5140 }
5141 }
5142}
5143
5144fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
5148 Ok(match value {
5149 Value::Null => SqlValue::Null,
5150 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
5151 Value::Number(n) => {
5152 if let Some(i) = n.as_i64() {
5153 SqlValue::Integer(i)
5154 } else if let Some(f) = n.as_f64() {
5155 SqlValue::Real(f)
5156 } else {
5157 return Err(format!("query param number {n} is out of range"));
5158 }
5159 }
5160 Value::String(s) => SqlValue::Text(s.clone()),
5161 Value::Object(_) => {
5162 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
5163 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
5164 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
5165 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
5166 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
5167 })?)
5168 } else {
5169 return Err(
5170 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
5171 .to_owned(),
5172 );
5173 }
5174 }
5175 Value::Array(_) => return Err("query array params are not supported".to_owned()),
5176 })
5177}
5178
5179fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
5184 use rusqlite::types::ValueRef;
5185 match value {
5186 ValueRef::Null => Value::Null,
5187 ValueRef::Integer(i) => {
5188 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
5192 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
5193 Value::from(i)
5194 } else {
5195 let mut map = Map::new();
5196 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
5197 Value::Object(map)
5198 }
5199 }
5200 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5201 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5202 ValueRef::Blob(b) => {
5203 let mut map = Map::new();
5204 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5205 Value::Object(map)
5206 }
5207 }
5208}
5209
5210fn query_connection(
5212 conn: &Connection,
5213 sql: &str,
5214 params: &[QueryValue],
5215) -> Result<Vec<QueryRow>, QueryReadFailure> {
5216 crate::query_guard::assert_read_only_query(sql)?;
5217 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
5218 let bound: Vec<SqlValue> = params
5219 .iter()
5220 .map(json_param_to_sql)
5221 .collect::<Result<_, _>>()?;
5222 let mut stmt = conn.prepare(&lowered_sql)?;
5223 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
5224 let bound_refs: Vec<&dyn rusqlite::ToSql> =
5225 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
5226 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
5227 let mut out = Vec::new();
5228 while let Some(row) = sql_rows.next()? {
5229 let mut record = Map::new();
5230 for (i, name) in column_names.iter().enumerate() {
5231 let value = row.get_ref(i)?;
5232 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
5233 }
5234 out.push(record);
5235 }
5236 Ok(out)
5237}
5238
5239fn persisted_window_state(
5240 conn: &Connection,
5241 base: &WindowBase,
5242) -> Result<WindowState, QueryReadFailure> {
5243 let mut stmt = conn.prepare(
5244 "SELECT windows.unit, subscriptions.state_json
5245 FROM _syncular_windows AS windows
5246 JOIN _syncular_subscriptions AS subscriptions
5247 ON subscriptions.id = windows.sub_id
5248 WHERE windows.base = ?1
5249 ORDER BY windows.unit ASC",
5250 )?;
5251 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
5252 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5253 })?;
5254 let mut units = Vec::new();
5255 let mut pending = Vec::new();
5256 for row in rows {
5257 let (unit, raw) = row?;
5258 let state: Value = serde_json::from_str(&raw)
5259 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
5260 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
5261 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
5262 || state
5263 .get("bootstrapState")
5264 .is_some_and(|value| !value.is_null());
5265 if is_pending {
5266 pending.push(unit.clone());
5267 }
5268 units.push(unit);
5269 }
5270 Ok(WindowState { units, pending })
5271}
5272
5273fn snapshot_connection(
5274 conn: &Connection,
5275 sql: &str,
5276 params: &[Value],
5277 coverage: &[WindowCoverage],
5278) -> Result<QuerySnapshot, QueryReadFailure> {
5279 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
5280 let result = (|| {
5281 let revision = conn
5282 .query_row(
5283 "SELECT value FROM _syncular_meta WHERE key = ?1",
5284 rusqlite::params![LOCAL_REVISION_KEY],
5285 |row| row.get::<_, String>(0),
5286 )
5287 .ok()
5288 .and_then(|value| value.parse::<u64>().ok())
5289 .unwrap_or(0);
5290 let rows = query_connection(conn, sql, params)?;
5291 let mut pending = Vec::new();
5292 let mut missing = Vec::new();
5293 for requested in coverage {
5294 let base_key = window_base_key(&requested.base);
5295 let state = persisted_window_state(conn, &requested.base)?;
5296 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5297 let reference = WindowUnitRef {
5298 base_key: base_key.clone(),
5299 unit: unit.clone(),
5300 };
5301 if !state.units.iter().any(|held| held == &unit) {
5302 missing.push(reference);
5303 } else if state.pending.iter().any(|held| held == &unit) {
5304 pending.push(reference);
5305 }
5306 }
5307 }
5308 Ok(QuerySnapshot {
5309 revision: revision.to_string(),
5310 rows,
5311 coverage: CoverageSnapshot {
5312 complete: pending.is_empty() && missing.is_empty(),
5313 pending,
5314 missing,
5315 },
5316 })
5317 })();
5318 match result {
5319 Ok(snapshot) => {
5320 conn.execute_batch("RELEASE syncular_snapshot_read")?;
5321 Ok(snapshot)
5322 }
5323 Err(error) => {
5324 let _ = conn.execute_batch(
5325 "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
5326 );
5327 Err(error)
5328 }
5329 }
5330}
5331
5332pub struct FileQuerySnapshotReader {
5337 path: String,
5338 conn: Option<Connection>,
5339}
5340
5341impl FileQuerySnapshotReader {
5342 #[must_use]
5343 pub fn new(path: impl Into<String>) -> Self {
5344 Self {
5345 path: path.into(),
5346 conn: None,
5347 }
5348 }
5349
5350 fn connection(&mut self) -> Result<&Connection, String> {
5351 if self.conn.is_none() {
5352 let conn = Connection::open_with_flags(
5353 &self.path,
5354 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5355 )
5356 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5357 conn.busy_timeout(std::time::Duration::from_millis(250))
5358 .map_err(|error| error.to_string())?;
5359 self.conn = Some(conn);
5360 }
5361 self.conn
5362 .as_ref()
5363 .ok_or_else(|| "read sidecar connection missing".to_owned())
5364 }
5365
5366 pub fn query_snapshot(
5369 &mut self,
5370 sql: &str,
5371 params: &[Value],
5372 coverage: &[WindowCoverage],
5373 ) -> Result<QuerySnapshot, QueryReadFailure> {
5374 snapshot_connection(self.connection()?, sql, params, coverage)
5375 }
5376}
5377
5378impl SyncClient {
5379 pub fn new_with_identity(
5380 client_id: Option<String>,
5381 schema_json: &Value,
5382 limits: ClientLimits,
5383 ) -> Result<Self, String> {
5384 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5385 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5386 Self::with_connection(resolved, schema_json, limits, conn)
5387 }
5388
5389 pub fn new(
5390 client_id: String,
5391 schema_json: &Value,
5392 limits: ClientLimits,
5393 ) -> Result<Self, String> {
5394 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5395 Self::with_connection(client_id, schema_json, limits, conn)
5396 }
5397
5398 pub fn open_path(
5404 client_id: String,
5405 schema_json: &Value,
5406 limits: ClientLimits,
5407 path: &str,
5408 ) -> Result<Self, String> {
5409 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5410 Self::with_connection(client_id, schema_json, limits, conn)
5411 }
5412
5413 pub fn open_path_with_identity(
5414 client_id: Option<String>,
5415 schema_json: &Value,
5416 limits: ClientLimits,
5417 path: &str,
5418 ) -> Result<Self, String> {
5419 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5420 conn.busy_timeout(std::time::Duration::from_millis(250))
5426 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5427 conn.pragma_update(None, "journal_mode", "WAL")
5428 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5429 let persisted = conn
5430 .query_row(
5431 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5432 [],
5433 |row| row.get::<_, String>(0),
5434 )
5435 .ok();
5436 let resolved = persisted
5437 .clone()
5438 .or(client_id.clone())
5439 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5440 if let (Some(existing), Some(requested)) = (persisted, client_id) {
5441 if existing != requested {
5442 return Err(format!(
5443 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5444 ));
5445 }
5446 }
5447 Self::with_connection(resolved, schema_json, limits, conn)
5448 }
5449
5450 #[must_use]
5451 pub fn client_id(&self) -> &str {
5452 &self.client_id
5453 }
5454
5455 pub fn with_connection(
5462 client_id: String,
5463 schema_json: &Value,
5464 limits: ClientLimits,
5465 conn: Connection,
5466 ) -> Result<Self, String> {
5467 if limits.outcome_retention_max_entries == Some(0) {
5468 return Err(
5469 "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5470 );
5471 }
5472 let schema = parse_schema_json(schema_json)?;
5473 let previous_version = match limits.previous_version_context {
5476 Some(config) => {
5477 config.validate()?;
5478 Some(config)
5479 }
5480 None => None,
5481 };
5482 let mut client = SyncClient {
5483 #[cfg(feature = "bench-internals")]
5484 benchmark_phases: Recorder::default(),
5485 conn,
5486 schema,
5487 client_id,
5488 limits,
5489 subs: Vec::new(),
5490 outbox: Vec::new(),
5491 conflicts: Vec::new(),
5492 rejections: Vec::new(),
5493 schema_floor: None,
5494 lease_state: None,
5495 stopped: false,
5496 upgrading: false,
5497 sync_needed: false,
5498 realtime_policy: RealtimePolicy::Optional,
5499 realtime_state: RealtimeState::Disconnected,
5500 realtime_reason_code: None,
5501 realtime_retry_delay_ms: None,
5502 presence: HashMap::new(),
5503 now_ms: None,
5504 encryption: crate::values::EncryptionConfig::default(),
5505 security_preflight: false,
5506 previous_version,
5507 insert_sql: RefCell::new(HashMap::new()),
5508 overlay_dirty: Cell::new(false),
5509 #[cfg(test)]
5510 overlay_rebuild_count: Cell::new(0),
5511 #[cfg(test)]
5512 outcome_prune_count: Cell::new(0),
5513 progress: ProgressObserver::default(),
5514 change_queue: VecDeque::new(),
5515 sync_intent_queue: VecDeque::new(),
5516 retry_delay_ms: 250,
5517 round_retry_delay_ms: None,
5518 last_round: None,
5519 last_change: None,
5520 query_failures: Vec::new(),
5521 };
5522 client
5526 .conn
5527 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5528 client.create_bookkeeping_tables()?;
5532 match client.get_meta(CLIENT_ID_KEY) {
5533 Some(existing) if existing != client.client_id => {
5534 return Err(format!(
5535 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5536 client.client_id
5537 ));
5538 }
5539 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5540 _ => {}
5541 }
5542 client.restore_persisted_state()?;
5543 let marker = client
5544 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5545 .and_then(|value| value.parse::<i32>().ok());
5546 match marker {
5547 None => {
5548 client.create_synced_tables()?;
5549 client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5550 set_local_schema_descriptor(&client.conn, &client.schema);
5552 }
5553 Some(version) if version == client.schema.version => {
5554 client.create_synced_tables()?;
5555 set_local_schema_descriptor(&client.conn, &client.schema);
5559 }
5560 Some(_) => client.run_schema_reset()?,
5561 }
5562 client.reconcile_previous_version_at_boot()?;
5568 client.clear_satisfied_persisted_schema_floor();
5569 client.prune_unknown_subscriptions()?;
5570 if marker == Some(client.schema.version) && !client.outbox.is_empty() {
5571 client.overlay_dirty.set(true);
5574 client.rebuild_overlay();
5575 }
5576 client.enqueue_startup_sync_if_needed()?;
5582 Ok(client)
5583 }
5584
5585 pub fn set_now_ms(&mut self, now_ms: i64) {
5588 self.now_ms = Some(now_ms);
5589 }
5590
5591 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
5595 self.encryption = encryption;
5596 }
5597
5598 #[must_use]
5599 pub fn security_lifecycle(&self) -> &'static str {
5600 if self.security_preflight {
5601 "preflight"
5602 } else {
5603 "active"
5604 }
5605 }
5606
5607 #[must_use]
5608 pub fn security_preflight(&self) -> bool {
5609 self.security_preflight
5610 }
5611
5612 pub fn begin_security_preflight(&mut self) {
5619 self.seal_security_on_teardown();
5620 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
5623 }
5624
5625 pub fn seal_security_on_teardown(&mut self) {
5635 self.security_preflight = true;
5636 self.encryption = crate::values::EncryptionConfig::default();
5637 self.sync_intent_queue.clear();
5638 }
5639
5640 pub fn activate_security(
5642 &mut self,
5643 encryption: crate::values::EncryptionConfig,
5644 ) -> Result<(), String> {
5645 if !self.security_preflight {
5646 return Err(
5647 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
5648 );
5649 }
5650 self.enqueue_startup_sync_if_needed()?;
5651 self.encryption = encryption;
5652 self.security_preflight = false;
5653 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
5654 Ok(())
5655 }
5656
5657 fn clock_now_ms(&self) -> i64 {
5658 self.now_ms.unwrap_or_else(|| {
5659 std::time::SystemTime::now()
5660 .duration_since(std::time::UNIX_EPOCH)
5661 .map(|d| d.as_millis() as i64)
5662 .unwrap_or(0)
5663 })
5664 }
5665
5666 fn create_bookkeeping_tables(&self) -> Result<(), String> {
5667 self.conn
5669 .execute_batch(
5670 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
5671 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5672 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
5673 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
5674 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5675 client_commit_id TEXT NOT NULL UNIQUE,
5676 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
5677 recorded_at_ms INTEGER NOT NULL,
5678 results_json TEXT NOT NULL,
5679 operations_json TEXT,
5680 resolution TEXT NOT NULL DEFAULT 'active'
5681 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
5682 resolved_at_ms INTEGER,
5683 replacement_client_commit_id TEXT);
5684 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
5685 ON _syncular_commit_outcomes(resolution, seq);
5686 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
5687 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
5688 CREATE TABLE IF NOT EXISTS _syncular_meta (
5689 key TEXT PRIMARY KEY, value TEXT NOT NULL);
5690 CREATE TABLE IF NOT EXISTS _syncular_windows (
5691 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
5692 PRIMARY KEY (base, unit));
5693 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
5694 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
5695 effective_scopes TEXT NOT NULL);
5696 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
5697 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
5698 PRIMARY KEY(commit_id, blob_id));
5699 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
5700 ON _syncular_blob_commit_refs(blob_id);",
5701 )
5702 .map_err(|e| e.to_string())?;
5703 let _ = self
5706 .conn
5707 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
5708 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
5709 self.set_meta(LOCAL_REVISION_KEY, "0");
5710 }
5711 if self.schema_has_blobs() {
5714 let exists = self
5715 .conn
5716 .query_row(
5717 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5718 [],
5719 |row| row.get::<_, bool>(0),
5720 )
5721 .map_err(|e| e.to_string())?;
5722 if exists {
5723 let mut statement = self
5724 .conn
5725 .prepare("PRAGMA table_info(_syncular_blobs)")
5726 .map_err(|e| e.to_string())?;
5727 let mut columns = statement
5728 .query_map([], |row| row.get::<_, String>(1))
5729 .map_err(|e| e.to_string())?
5730 .collect::<Result<Vec<_>, _>>()
5731 .map_err(|e| e.to_string())?;
5732 columns.sort();
5733 let mut expected = vec![
5734 "blob_id",
5735 "byte_length",
5736 "bytes",
5737 "created_at_ms",
5738 "media_type",
5739 ];
5740 expected.sort_unstable();
5741 if columns != expected {
5742 return Err(
5743 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5744 );
5745 }
5746 } else {
5747 self.conn
5748 .execute_batch(
5749 "CREATE TABLE _syncular_blobs(
5750 blob_id TEXT PRIMARY KEY,
5751 bytes BLOB NOT NULL,
5752 byte_length INTEGER NOT NULL,
5753 media_type TEXT,
5754 created_at_ms INTEGER NOT NULL);",
5755 )
5756 .map_err(|e| e.to_string())?;
5757 }
5758 self.conn
5759 .execute_batch(
5760 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5761 blob_id TEXT PRIMARY KEY,
5762 media_type TEXT,
5763 created_at_ms INTEGER NOT NULL);",
5764 )
5765 .map_err(|e| e.to_string())?;
5766 }
5767 Ok(())
5768 }
5769
5770 fn schema_has_blobs(&self) -> bool {
5772 self.schema
5773 .tables
5774 .iter()
5775 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5776 }
5777
5778 fn get_meta(&self, key: &str) -> Option<String> {
5781 meta_get(&self.conn, key)
5782 }
5783
5784 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5785 self.conn
5786 .query_row(
5787 "SELECT value FROM _syncular_meta WHERE key = ?1",
5788 rusqlite::params![key],
5789 |row| row.get::<_, String>(0),
5790 )
5791 .optional()
5792 .map_err(|_| {
5793 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5794 })
5795 }
5796
5797 fn set_meta(&self, key: &str, value: &str) {
5798 meta_set(&self.conn, key, value);
5799 }
5800
5801 fn delete_meta(&self, key: &str) {
5802 meta_delete(&self.conn, key);
5803 }
5804
5805 fn restore_persisted_state(&mut self) -> Result<(), String> {
5806 self.subs = {
5807 let mut stmt = self
5808 .conn
5809 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5810 .map_err(|error| error.to_string())?;
5811 let rows = stmt
5812 .query_map([], |row| {
5813 Ok((
5814 row.get::<_, String>(0)?,
5815 row.get::<_, String>(1)?,
5816 row.get::<_, String>(2)?,
5817 ))
5818 })
5819 .map_err(|error| error.to_string())?;
5820 let mut subscriptions = Vec::new();
5821 for row in rows {
5822 let (id, table, raw) = row.map_err(|error| error.to_string())?;
5823 let state: Value = serde_json::from_str(&raw)
5824 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5825 let requested = json_to_scope_map(
5826 state.get("requested").unwrap_or(&Value::Object(Map::new())),
5827 )?;
5828 let effective = state
5829 .get("effectiveScopes")
5830 .filter(|value| !value.is_null())
5831 .map(json_to_scope_map)
5832 .transpose()?;
5833 subscriptions.push(Subscription {
5834 id,
5835 table,
5836 requested,
5837 params: state
5838 .get("params")
5839 .and_then(Value::as_str)
5840 .map(str::to_owned),
5841 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
5842 bootstrap_state: state
5843 .get("bootstrapState")
5844 .and_then(Value::as_str)
5845 .map(str::to_owned),
5846 state: SubState::parse(
5847 state
5848 .get("status")
5849 .and_then(Value::as_str)
5850 .unwrap_or("active"),
5851 ),
5852 reason_code: state
5853 .get("reasonCode")
5854 .and_then(Value::as_str)
5855 .map(str::to_owned),
5856 effective,
5857 synced_once: state
5858 .get("syncedOnce")
5859 .and_then(Value::as_bool)
5860 .unwrap_or(false),
5861 });
5862 }
5863 subscriptions
5864 };
5865
5866 self.outbox = {
5867 let mut stmt = self
5868 .conn
5869 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
5870 .map_err(|error| error.to_string())?;
5871 let rows = stmt
5872 .query_map([], |row| {
5873 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5874 })
5875 .map_err(|error| error.to_string())?;
5876 let mut commits = Vec::new();
5877 for row in rows {
5878 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
5879 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
5880 format!("invalid persisted outbox {client_commit_id:?}: {error}")
5881 })?;
5882 let mut ops = Vec::with_capacity(entries.len());
5883 for entry in entries {
5884 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
5885 ops.push(OutboxOp {
5886 upsert: op == "upsert",
5887 table: entry
5888 .get("table")
5889 .and_then(Value::as_str)
5890 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
5891 .to_owned(),
5892 row_id: entry
5893 .get("rowId")
5894 .and_then(Value::as_str)
5895 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
5896 .to_owned(),
5897 base_version: entry.get("baseVersion").and_then(Value::as_i64),
5898 values: entry.get("values").and_then(Value::as_object).cloned(),
5901 });
5902 }
5903 commits.push(OutboxCommit {
5904 client_commit_id,
5905 ops,
5906 });
5907 }
5908 commits
5909 };
5910
5911 self.prune_commit_outcomes()?;
5912 let active = self.commit_outcomes(CommitOutcomeQuery {
5913 active_only: true,
5914 ..CommitOutcomeQuery::default()
5915 })?;
5916 self.conflicts = active
5917 .iter()
5918 .flat_map(|outcome| outcome.results.iter())
5919 .filter_map(|result| match result {
5920 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
5921 _ => None,
5922 })
5923 .collect();
5924 self.rejections = active
5925 .iter()
5926 .flat_map(|outcome| outcome.results.iter())
5927 .filter_map(|result| match result {
5928 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
5929 _ => None,
5930 })
5931 .collect();
5932
5933 self.lease_state = self
5934 .get_meta(LEASE_STATE_KEY)
5935 .map(|raw| serde_json::from_str(&raw))
5936 .transpose()
5937 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
5938 self.schema_floor = self
5939 .get_meta(SCHEMA_FLOOR_KEY)
5940 .map(|raw| serde_json::from_str(&raw))
5941 .transpose()
5942 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
5943 self.stopped = self.schema_floor.is_some();
5944 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
5947 self.security_preflight = true;
5948 }
5949 Ok(())
5950 }
5951
5952 fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
5955 let valid_tables: BTreeSet<String> = self
5956 .schema
5957 .tables
5958 .iter()
5959 .map(|table| table.name.clone())
5960 .collect();
5961 let stale_ids: Vec<String> = self
5962 .subs
5963 .iter()
5964 .filter(|sub| !valid_tables.contains(&sub.table))
5965 .map(|sub| sub.id.clone())
5966 .collect();
5967 for id in &stale_ids {
5968 self.conn
5969 .execute(
5970 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
5971 rusqlite::params![id],
5972 )
5973 .map_err(|error| error.to_string())?;
5974 self.conn
5975 .execute(
5976 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
5977 rusqlite::params![id],
5978 )
5979 .map_err(|error| error.to_string())?;
5980 self.conn
5981 .execute(
5982 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
5983 rusqlite::params![id],
5984 )
5985 .map_err(|error| error.to_string())?;
5986 }
5987 self.subs.retain(|sub| valid_tables.contains(&sub.table));
5988 Ok(())
5989 }
5990
5991 #[must_use]
5992 pub fn local_revision(&self) -> u64 {
5993 self.get_meta(LOCAL_REVISION_KEY)
5994 .and_then(|value| value.parse().ok())
5995 .unwrap_or(0)
5996 }
5997
5998 #[must_use]
5999 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
6000 SyncStatusSnapshot {
6001 current_schema_version: self.schema.version,
6002 outbox: self.outbox.len(),
6003 upgrading: self.upgrading,
6004 lease_state: self.lease_state.clone(),
6005 schema_floor: self.schema_floor.clone(),
6006 sync_needed: self.sync_needed,
6007 previous_version_context: self.previous_version_status(),
6008 }
6009 }
6010
6011 fn previous_version_status(&self) -> PreviousVersionStatus {
6014 let Some(replica_path) = self.previous_version_replica_path() else {
6015 return PreviousVersionStatus {
6016 present: false,
6017 created_at_ms: None,
6018 };
6019 };
6020 match crate::previous_version::read_previous_version_record(&replica_path) {
6021 Ok(Some(record)) => PreviousVersionStatus {
6022 present: true,
6023 created_at_ms: Some(record.created_at_ms),
6024 },
6025 _ => PreviousVersionStatus {
6026 present: std::path::Path::new(
6027 &crate::previous_version::previous_version_container_path(&replica_path),
6028 )
6029 .exists(),
6030 created_at_ms: None,
6031 },
6032 }
6033 }
6034
6035 pub fn previous_version_snapshot(
6041 &self,
6042 spec: &PreviousVersionReadSpec,
6043 ) -> Result<PreviousVersionSnapshot, String> {
6044 let now_ms = self.clock_now_ms();
6045 crate::previous_version::previous_version_snapshot(
6046 &self.conn,
6047 self.previous_version_replica_path().as_deref(),
6048 self.previous_version.as_ref(),
6049 PreviousVersionLifecycle {
6050 lease_inactive: self.previous_version_lease_inactive(now_ms),
6051 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
6052 coverage_complete: self.previous_version_coverage_complete(),
6053 },
6054 now_ms,
6055 self.schema.version,
6056 spec,
6057 )
6058 }
6059
6060 #[must_use]
6062 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
6063 crate::previous_version::stored_previous_version_audit(&self.conn)
6064 }
6065
6066 pub fn previous_version_discard(
6070 &self,
6071 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
6072 self.drop_previous_version()
6073 }
6074
6075 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
6079 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
6080 present: false,
6081 discarded: false,
6082 };
6083 let Some(replica_path) = self.previous_version_replica_path() else {
6084 return absent;
6085 };
6086 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
6087 .unwrap_or(absent)
6088 }
6089
6090 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
6092 let Some(lease) = &self.lease_state else {
6093 return false;
6094 };
6095 if lease.error_code.is_some() {
6096 return true;
6097 }
6098 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
6099 }
6100
6101 fn previous_version_coverage_complete(&self) -> bool {
6105 let active = self
6106 .subs
6107 .iter()
6108 .filter(|sub| sub.state == SubState::Active)
6109 .collect::<Vec<_>>();
6110 if active.is_empty() {
6111 return false;
6112 }
6113 active
6114 .iter()
6115 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
6116 }
6117
6118 fn previous_version_lifetime_check(&self) {
6122 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
6123 return;
6124 };
6125 let now_ms = self.clock_now_ms();
6126 if self.previous_version_lease_inactive(now_ms) {
6127 let _ = self.drop_previous_version();
6128 return;
6129 }
6130 let Some(replica_path) = self.previous_version_replica_path() else {
6131 return;
6132 };
6133 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
6134 else {
6135 return;
6136 };
6137 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
6138 let _ = self.drop_previous_version();
6139 return;
6140 }
6141 if now_ms - record.created_at_ms > config.max_age_ms {
6142 let _ = self.drop_previous_version();
6143 return;
6144 }
6145 if self.previous_version_coverage_complete() {
6146 let _ = self.drop_previous_version();
6147 }
6148 }
6149
6150 pub fn diagnostics_snapshot(
6151 &self,
6152 request: &ClientDiagnosticsRequest,
6153 ) -> Result<ClientDiagnosticsSnapshot, String> {
6154 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
6155 return Err(format!(
6156 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
6157 ));
6158 }
6159 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
6160 for sub in &self.subs {
6161 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
6162 let complete =
6163 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
6164 let state = match sub.state {
6165 SubState::Revoked => "revoked",
6166 SubState::Failed => "failed",
6167 SubState::Active if reset => "reset",
6168 SubState::Active if complete => "complete",
6169 SubState::Active => "bootstrapping",
6170 };
6171 subscriptions.insert(
6172 sub.id.clone(),
6173 DiagnosticSubscription {
6174 id: sub.id.clone(),
6175 table: sub.table.clone(),
6176 state: state.to_owned(),
6177 complete,
6178 cursor: Some(sub.cursor),
6179 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
6180 },
6181 );
6182 }
6183 for expected in &request.expected_subscriptions {
6184 if expected.id.is_empty() || expected.table.is_empty() {
6185 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
6186 }
6187 if subscriptions
6188 .get(&expected.id)
6189 .is_some_and(|registered| registered.table != expected.table)
6190 {
6191 subscriptions.insert(
6192 expected.id.clone(),
6193 DiagnosticSubscription {
6194 id: expected.id.clone(),
6195 table: expected.table.clone(),
6196 state: "failed".to_owned(),
6197 complete: false,
6198 cursor: None,
6199 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
6200 },
6201 );
6202 } else {
6203 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
6204 DiagnosticSubscription {
6205 id: expected.id.clone(),
6206 table: expected.table.clone(),
6207 state: "unregistered".to_owned(),
6208 complete: false,
6209 cursor: None,
6210 reason_code: None,
6211 }
6212 });
6213 }
6214 }
6215 let mut ordered_subscriptions = Vec::new();
6216 let mut included = BTreeSet::new();
6217 for expected in &request.expected_subscriptions {
6218 if included.insert(expected.id.clone()) {
6219 if let Some(subscription) = subscriptions.get(&expected.id) {
6220 ordered_subscriptions.push(subscription.clone());
6221 }
6222 }
6223 }
6224 for (id, subscription) in &subscriptions {
6225 if included.insert(id.clone()) {
6226 ordered_subscriptions.push(subscription.clone());
6227 }
6228 }
6229 let subscriptions_truncated =
6230 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
6231 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
6232 let captured_at_ms = self.clock_now_ms();
6233 let lease = if let Some(error_code) = self
6234 .lease_state
6235 .as_ref()
6236 .and_then(|state| state.error_code.clone())
6237 {
6238 ClientDiagnosticsLease {
6239 state: "stopped".to_owned(),
6240 expires_at_ms: self
6241 .lease_state
6242 .as_ref()
6243 .and_then(|state| state.expires_at_ms),
6244 error_code: Some(Self::diagnostic_code(&error_code)),
6245 }
6246 } else if let Some(expires_at_ms) = self
6247 .lease_state
6248 .as_ref()
6249 .and_then(|state| state.expires_at_ms)
6250 {
6251 ClientDiagnosticsLease {
6252 state: if expires_at_ms <= captured_at_ms {
6253 "expired".to_owned()
6254 } else {
6255 "active".to_owned()
6256 },
6257 expires_at_ms: Some(expires_at_ms),
6258 error_code: None,
6259 }
6260 } else {
6261 ClientDiagnosticsLease {
6262 state: "none".to_owned(),
6263 expires_at_ms: None,
6264 error_code: None,
6265 }
6266 };
6267 let connectivity = match self.last_round.as_ref() {
6268 Some(round) if round.status == "succeeded" => "online",
6269 Some(round)
6270 if round.status == "failed"
6271 && round
6272 .error_code
6273 .as_deref()
6274 .is_some_and(Self::retryable_transport_code) =>
6275 {
6276 "offline"
6277 }
6278 _ => "unknown",
6279 };
6280 Ok(ClientDiagnosticsSnapshot {
6281 version: CLIENT_DIAGNOSTICS_VERSION,
6282 captured_at_ms,
6283 host: ClientDiagnosticsHost {
6284 kind: "direct".to_owned(),
6285 role: "single".to_owned(),
6286 connectivity: connectivity.to_owned(),
6287 realtime: self.realtime_state,
6288 realtime_policy: self.realtime_policy,
6289 realtime_reason_code: self.realtime_reason_code.clone(),
6290 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
6291 },
6292 security_lifecycle: self.security_lifecycle().to_owned(),
6293 schema: ClientDiagnosticsSchema {
6294 current_version: self.schema.version,
6295 upgrading: self.upgrading,
6296 required_version: self
6297 .schema_floor
6298 .as_ref()
6299 .and_then(|floor| floor.required_schema_version),
6300 latest_version: self
6301 .schema_floor
6302 .as_ref()
6303 .and_then(|floor| floor.latest_schema_version),
6304 },
6305 replica: ClientDiagnosticsReplica {
6306 local_revision: self.local_revision().to_string(),
6307 sync_needed: self.sync_needed,
6308 pending_outbox: self.outbox.len(),
6309 },
6310 lease,
6311 subscriptions: ordered_subscriptions,
6312 subscriptions_truncated,
6313 last_round: self.last_round.clone(),
6314 last_change: self.last_change.clone(),
6315 storage: self.diagnostics_storage(),
6316 query_failures: self.query_failures.clone(),
6317 })
6318 }
6319
6320 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
6321 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
6322 let page_count: i64 = self
6323 .conn
6324 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
6325 let page_size: i64 = self
6326 .conn
6327 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
6328 let outbox_bytes: i64 = self
6330 .conn
6331 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
6332 .query_row([], |row| row.get(0))?;
6333 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
6334 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
6335 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
6336 let blob_bytes = if self.schema_has_blobs() {
6337 self.conn
6338 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
6339 .query_row([], |row| row.get(0))?
6340 } else {
6341 0
6342 };
6343 let pressure = self
6344 .limits
6345 .blob_cache_max_bytes
6346 .is_some_and(|limit| blob_bytes > limit);
6347 Ok(ClientDiagnosticsStorage {
6348 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
6349 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
6350 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
6351 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
6352 retained_outcome_entries: Some(outcome_entries.max(0)),
6353 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
6354 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
6355 })
6356 };
6357 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
6358 status: "unreadable".to_owned(),
6359 database_bytes_approx: None,
6360 pending_outbox_bytes_approx: None,
6361 retained_outcome_bytes_approx: None,
6362 retained_outcome_entries: None,
6363 blob_cache_bytes_approx: None,
6364 pressure_reason_code: None,
6365 })
6366 }
6367
6368 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
6369 self.change_queue.drain(..).collect()
6370 }
6371
6372 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
6373 self.sync_intent_queue.drain(..).collect()
6374 }
6375
6376 fn schedule_background_retry(&mut self) -> u64 {
6377 let delay_ms = self.retry_delay_ms;
6378 self.sync_intent_queue
6379 .push_back(SyncIntent::Background { delay_ms });
6380 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
6381 self.round_retry_delay_ms = Some(delay_ms);
6382 delay_ms
6383 }
6384
6385 fn reset_background_retry(&mut self) {
6386 self.retry_delay_ms = 250;
6387 }
6388
6389 fn retryable_transport_code(code: &str) -> bool {
6390 code == "transport.failed"
6391 || code == "transport.unavailable"
6392 || code == "sync.transport_failed"
6393 }
6394
6395 fn retryable_failure_code(code: &str) -> bool {
6400 Self::retryable_transport_code(code)
6401 || matches!(
6402 code,
6403 "sync.auth_required"
6404 | "sync.auth_lease_required"
6405 | "sync.auth_lease_revoked"
6406 | "sync.internal_error"
6407 | "sync.idempotency_cache_miss"
6408 | "sync.schema_not_ready"
6409 | "sync.segment_expired"
6410 | "sync.rate_limited"
6411 | "sync.websocket_connection_limit"
6412 )
6413 }
6414
6415 fn diagnostic_code(code: &str) -> String {
6416 let valid = !code.is_empty()
6417 && code.len() <= 96
6418 && code.contains('.')
6419 && code
6420 .bytes()
6421 .next()
6422 .is_some_and(|byte| byte.is_ascii_lowercase())
6423 && code.bytes().all(|byte| {
6424 byte.is_ascii_lowercase()
6425 || byte.is_ascii_digit()
6426 || matches!(byte, b'.' | b'_' | b'-')
6427 });
6428 if valid {
6429 code.to_owned()
6430 } else {
6431 "client.unknown_failure".to_owned()
6432 }
6433 }
6434
6435 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
6436 if self.sync_needed != value {
6437 if self.begin_observation("syncular_status").is_ok() {
6438 self.sync_needed = value;
6439 let batch = ChangeAccumulator {
6440 status: true,
6441 ..ChangeAccumulator::default()
6442 };
6443 if self.finish_observation("syncular_status", batch).is_err() {
6444 self.rollback_observation("syncular_status");
6445 }
6446 } else {
6447 self.sync_needed = value;
6448 }
6449 }
6450 if value && interactive {
6451 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6452 }
6453 }
6454
6455 fn begin_observation(&self, name: &str) -> Result<(), String> {
6456 self.conn
6457 .execute_batch(&format!("SAVEPOINT {name}"))
6458 .map_err(|error| error.to_string())
6459 }
6460
6461 fn rollback_observation(&self, name: &str) {
6462 let _ = self
6463 .conn
6464 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
6465 }
6466
6467 fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
6468 #[cfg(feature = "bench-internals")]
6469 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
6470 if !batch.touched() {
6471 self.conn
6472 .execute_batch(&format!("RELEASE {name}"))
6473 .map_err(|error| error.to_string())?;
6474 return Ok(());
6475 }
6476 let revision = self
6477 .local_revision()
6478 .checked_add(1)
6479 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
6480 self.conn
6481 .execute(
6482 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
6483 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
6484 )
6485 .map_err(|error| error.to_string())?;
6486 let status = batch.status.then(|| self.status_snapshot());
6487 let event = ClientChangeBatch {
6488 revision: revision.to_string(),
6489 tables: batch
6490 .tables
6491 .into_iter()
6492 .map(|(table, scope_keys)| TableChange {
6493 table,
6494 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
6495 })
6496 .collect(),
6497 windows: batch
6498 .windows
6499 .into_iter()
6500 .map(|((base_key, table), units)| WindowChange {
6501 base_key,
6502 table,
6503 units: units.into_iter().collect(),
6504 })
6505 .collect(),
6506 status,
6507 conflicts_changed: batch.conflicts,
6508 rejections_changed: batch.rejections,
6509 outcomes_changed: batch.outcomes,
6510 };
6511 self.conn
6512 .execute_batch(&format!("RELEASE {name}"))
6513 .map_err(|error| error.to_string())?;
6514 let mut diagnostic_tables = event
6515 .tables
6516 .iter()
6517 .map(|entry| entry.table.clone())
6518 .collect::<BTreeSet<_>>()
6519 .into_iter()
6520 .collect::<Vec<_>>();
6521 let mut diagnostic_windows = event
6522 .windows
6523 .iter()
6524 .map(|entry| entry.table.clone())
6525 .collect::<BTreeSet<_>>()
6526 .into_iter()
6527 .collect::<Vec<_>>();
6528 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
6529 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
6530 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
6531 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
6532 self.last_change = Some(DiagnosticLastChange {
6533 revision: event.revision.clone(),
6534 recorded_at_ms: self.clock_now_ms(),
6535 tables: diagnostic_tables,
6536 windows: diagnostic_windows,
6537 domains_truncated,
6538 status_changed: event.status.is_some(),
6539 conflicts_changed: event.conflicts_changed,
6540 rejections_changed: event.rejections_changed,
6541 outcomes_changed: event.outcomes_changed,
6542 });
6543 self.change_queue.push_back(event);
6544 Ok(())
6545 }
6546
6547 fn record_scope_map(
6548 &self,
6549 batch: &mut ChangeAccumulator,
6550 table_name: &str,
6551 scopes: &[(String, Vec<String>)],
6552 ) {
6553 let Some(table) = self.schema.table(table_name) else {
6554 return;
6555 };
6556 for (variable, values) in scopes {
6557 let Some(scope) = table
6558 .scope_variables
6559 .iter()
6560 .find(|scope| &scope.variable == variable)
6561 else {
6562 continue;
6563 };
6564 for value in values {
6565 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6566 }
6567 }
6568 }
6569
6570 fn record_row_scopes(
6572 &self,
6573 batch: &mut ChangeAccumulator,
6574 table_name: &str,
6575 row_id: &str,
6576 base: bool,
6577 ) -> bool {
6578 let Some(table) = self.schema.table(table_name) else {
6579 return false;
6580 };
6581 if table.scope_variables.is_empty() {
6582 return false;
6583 }
6584 let columns = table
6585 .scope_variables
6586 .iter()
6587 .map(|scope| quote_ident(&scope.column))
6588 .collect::<Vec<_>>()
6589 .join(", ");
6590 let full_table = if base {
6591 base_table(table_name)
6592 } else {
6593 visible_table(table_name)
6594 };
6595 let sql = format!(
6596 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
6597 row_id_predicate(table)
6598 );
6599 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
6600 return false;
6601 };
6602 let values = stmt.query_row(rusqlite::params![row_id], |row| {
6603 let mut values = Vec::with_capacity(table.scope_variables.len());
6604 for index in 0..table.scope_variables.len() {
6605 values.push(row.get::<_, Option<String>>(index)?);
6606 }
6607 Ok(values)
6608 });
6609 let Ok(values) = values else {
6610 return false;
6611 };
6612 let mut recorded = false;
6613 for (scope, value) in table.scope_variables.iter().zip(values) {
6614 if let Some(value) = value {
6615 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6616 recorded = true;
6617 }
6618 }
6619 recorded
6620 }
6621
6622 fn record_commit_changes(
6623 &self,
6624 batch: &mut ChangeAccumulator,
6625 tables: &[String],
6626 changes: &[ssp2::model::Change],
6627 ) {
6628 #[cfg(feature = "bench-internals")]
6629 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
6630 for change in changes {
6631 let Some(table_name) = tables.get(change.table_index as usize) else {
6632 continue;
6633 };
6634 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
6635 if let Some(table) = self.schema.table(table_name) {
6636 for (variable, value) in &change.scopes {
6637 if let Some(scope) = table
6638 .scope_variables
6639 .iter()
6640 .find(|scope| &scope.variable == variable)
6641 {
6642 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6643 precise = true;
6644 }
6645 }
6646 }
6647 if !precise {
6648 batch.table(table_name);
6649 }
6650 }
6651 }
6652
6653 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
6654 if effective.is_empty() {
6655 return false;
6656 }
6657 let Some(table) = self.schema.table(table_name) else {
6658 return false;
6659 };
6660 let mut clauses = Vec::new();
6661 let mut params = Vec::new();
6662 for (variable, values) in effective {
6663 let Some(column) = table.scope_column(variable) else {
6664 return false;
6665 };
6666 if values.is_empty() {
6667 return false;
6668 }
6669 let placeholders = values
6670 .iter()
6671 .map(|value| {
6672 params.push(SqlValue::Text(value.clone()));
6673 "?"
6674 })
6675 .collect::<Vec<_>>()
6676 .join(", ");
6677 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
6678 }
6679 let sql = format!(
6680 "SELECT 1 FROM {} WHERE {} LIMIT 1",
6681 base_table(table_name),
6682 clauses.join(" AND ")
6683 );
6684 self.conn
6685 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
6686 .is_ok()
6687 }
6688
6689 pub fn upgrading(&self) -> bool {
6691 self.upgrading
6692 }
6693
6694 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
6700 let new_schema = parse_schema_json(schema_json)?;
6701 let marker: Option<i32> = self
6702 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6703 .and_then(|v| v.parse().ok());
6704 self.schema = new_schema;
6705 self.create_bookkeeping_tables()?;
6706 if marker != Some(self.schema.version) {
6707 self.run_schema_reset()?;
6708 }
6709 self.prune_unknown_subscriptions()?;
6710 self.enqueue_startup_sync_if_needed()?;
6714 Ok(())
6715 }
6716
6717 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
6718 let startup_work = !self.stopped
6719 && (!self.outbox.is_empty()
6720 || !self.load_pending_evictions()?.is_empty()
6721 || self.subs.iter().any(|sub| sub.state == SubState::Active));
6722 if startup_work {
6723 self.sync_needed = true;
6724 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6725 }
6726 Ok(())
6727 }
6728
6729 fn clear_satisfied_persisted_schema_floor(&mut self) {
6736 let satisfied = self
6737 .schema_floor
6738 .as_ref()
6739 .and_then(|floor| floor.required_schema_version)
6740 .is_some_and(|required| self.schema.version >= required);
6741 if !satisfied {
6742 return;
6743 }
6744 self.schema_floor = None;
6745 self.stopped = false;
6746 self.delete_meta(SCHEMA_FLOOR_KEY);
6747 }
6748
6749 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
6754 let Some(replica_path) = self.previous_version_replica_path() else {
6755 return Ok(());
6756 };
6757 let max_age_ms = self
6758 .previous_version
6759 .filter(|config| config.enabled)
6760 .map(|config| config.max_age_ms);
6761 reconcile_previous_version_at_boot(
6762 &self.conn,
6763 &replica_path,
6764 self.schema.version,
6765 max_age_ms,
6766 self.clock_now_ms(),
6767 )
6768 }
6769
6770 fn previous_version_replica_path(&self) -> Option<String> {
6774 let path = self.conn.path()?;
6775 if path.is_empty() || path.starts_with("file:") {
6776 return None;
6777 }
6778 Some(path.to_owned())
6779 }
6780
6781 fn run_schema_reset(&mut self) -> Result<(), String> {
6787 self.begin_observation("syncular_schema_reset")?;
6788 let mut batch = ChangeAccumulator::default();
6789 let result = self.run_schema_reset_observed(&mut batch, true, true);
6790 if let Err(error) = result {
6791 self.rollback_observation("syncular_schema_reset");
6792 return Err(error);
6793 }
6794 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
6795 self.rollback_observation("syncular_schema_reset");
6796 return Err(error);
6797 }
6798 Ok(())
6799 }
6800
6801 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
6802 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
6803 self.begin_observation("syncular_log_epoch_reset")?;
6804 let mut batch = ChangeAccumulator::default();
6805 let result = self.run_schema_reset_observed(&mut batch, false, false);
6806 if let Err(error) = result {
6807 self.rollback_observation("syncular_log_epoch_reset");
6808 return Err(error);
6809 }
6810 self.set_meta(LOG_EPOCH_KEY, log_epoch);
6811 self.sync_needed = true;
6812 batch.status = true;
6813 if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
6814 self.rollback_observation("syncular_log_epoch_reset");
6815 return Err(error);
6816 }
6817 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6818 Ok(resets)
6819 }
6820
6821 fn run_schema_reset_observed(
6822 &mut self,
6823 batch: &mut ChangeAccumulator,
6824 drop_incompatible: bool,
6825 capture: bool,
6826 ) -> Result<(), String> {
6827 self.upgrading = true;
6828 batch.status = true;
6829 if self.previous_version.is_some_and(|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 let pending = self
6840 .outbox
6841 .iter()
6842 .map(|commit| PendingCommitAudit {
6843 commit_id: &commit.client_commit_id,
6844 operations: commit_audit_operations(commit),
6845 })
6846 .collect::<Vec<_>>();
6847 crate::previous_version::write_previous_version_audit(
6848 &self.conn,
6849 &crate::previous_version::build_previous_version_audit(
6850 &self.schema,
6851 &pending,
6852 previous_version,
6853 self.schema.version,
6854 self.clock_now_ms(),
6855 ),
6856 );
6857 }
6858 if let Some(replica_path) = self.previous_version_replica_path() {
6863 sweep_previous_version_container(&self.conn, &replica_path)?;
6864 if capture {
6865 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
6866 let previous_version = self
6867 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6868 .and_then(|value| value.parse::<i32>().ok())
6869 .unwrap_or(self.schema.version);
6870 capture_previous_version_from_replica(
6871 &self.conn,
6872 &replica_path,
6873 previous_version,
6874 self.schema.version,
6875 &config,
6876 self.clock_now_ms(),
6877 )?;
6878 }
6879 }
6880 }
6881 for table in &self.schema.tables {
6882 batch.table(&table.name);
6883 }
6884 for (base_key, unit, table) in self.load_registered_window_units() {
6885 batch.window(&base_key, &table, &unit);
6886 }
6887 self.insert_sql.borrow_mut().clear();
6889 self.overlay_dirty.set(true);
6890 let virtual_tables: Vec<String> = {
6898 let mut stmt = self
6899 .conn
6900 .prepare(
6901 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
6902 )
6903 .map_err(|e| e.to_string())?;
6904 let rows = stmt
6905 .query_map([], |row| row.get::<_, String>(0))
6906 .map_err(|e| e.to_string())?;
6907 rows.filter_map(Result::ok)
6908 .filter(|name| is_synced_table_name(name))
6909 .collect()
6910 };
6911 for name in virtual_tables {
6912 self.conn
6913 .execute_batch(&format!(
6914 "DROP TABLE IF EXISTS {}",
6915 quote_ident(&format!("_syncular_fts_{name}"))
6916 ))
6917 .map_err(|e| e.to_string())?;
6918 self.conn
6919 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6920 .map_err(|e| e.to_string())?;
6921 }
6922 let existing: Vec<String> = {
6923 let mut stmt = self
6924 .conn
6925 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
6926 .map_err(|e| e.to_string())?;
6927 let rows = stmt
6928 .query_map([], |row| row.get::<_, String>(0))
6929 .map_err(|e| e.to_string())?;
6930 rows.filter_map(Result::ok)
6931 .filter(|name| is_synced_table_name(name))
6932 .collect()
6933 };
6934 for name in &existing {
6935 if let Some(table) = name.strip_prefix("_syncular_base_") {
6936 batch.table(table);
6937 } else if !name.starts_with("_syncular_") {
6938 batch.table(name);
6939 }
6940 }
6941 for name in existing {
6942 self.conn
6943 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6944 .map_err(|e| e.to_string())?;
6945 }
6946 self.create_synced_tables()?;
6948 for sub in &mut self.subs {
6950 sub.cursor = -1;
6951 sub.bootstrap_state = None;
6952 sub.effective = None;
6953 sub.state = SubState::Active;
6954 sub.reason_code = None;
6955 sub.synced_once = false;
6956 }
6957 let subs = self.subs.clone();
6958 for sub in &subs {
6959 self.persist_sub(sub)?;
6960 }
6961 self.stopped = false;
6963 self.schema_floor = None;
6964 self.delete_meta(SCHEMA_FLOOR_KEY);
6965 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
6967 set_local_schema_descriptor(&self.conn, &self.schema);
6970 if drop_incompatible && self.drop_incompatible_outbox()? {
6974 batch.rejections = true;
6975 batch.status = true;
6976 batch.outcomes = true;
6977 }
6978 self.rebuild_overlay();
6980 Ok(())
6981 }
6982
6983 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
6987 let incompatible = self
6988 .outbox
6989 .iter()
6990 .filter(|commit| {
6991 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
6992 })
6993 .cloned()
6994 .collect::<Vec<_>>();
6995 if incompatible.is_empty() {
6996 return Ok(false);
6997 }
6998 let mut rejections = Vec::new();
6999 for commit in &incompatible {
7000 let results = commit
7001 .ops
7002 .iter()
7003 .enumerate()
7004 .map(|(op_index, operation)| {
7005 let rejection = RejectionRecord {
7006 client_commit_id: commit.client_commit_id.clone(),
7007 op_index: op_index as i32,
7008 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
7009 message: "the persisted commit cannot encode under the current schema"
7010 .to_owned(),
7011 retryable: false,
7012 details: None,
7013 operation: Some(CommitOperation::from(operation)),
7014 };
7015 rejections.push(rejection.clone());
7016 CommitOperationOutcome::Error { rejection }
7017 })
7018 .collect::<Vec<_>>();
7019 self.persist_commit_outcome(
7020 &commit.client_commit_id,
7021 CommitOutcomeStatus::Rejected,
7022 &results,
7023 Some(&commit.ops),
7024 )?;
7025 self.delete_outbox_persisted(&commit.client_commit_id)?;
7026 }
7027 self.prune_commit_outcomes()?;
7028 let incompatible_ids = incompatible
7029 .iter()
7030 .map(|commit| commit.client_commit_id.as_str())
7031 .collect::<BTreeSet<_>>();
7032 self.outbox
7033 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
7034 self.rejections.extend(rejections);
7035 Ok(true)
7036 }
7037
7038 fn create_synced_tables(&self) -> Result<(), String> {
7041 for table in &self.schema.tables {
7042 for (full, index_prefix) in [
7046 (base_table(&table.name), "_syncular_base_"),
7047 (visible_table(&table.name), ""),
7048 ] {
7049 let mut cols: Vec<String> =
7050 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
7051 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
7052 let sql = format!(
7053 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
7054 cols.join(", "),
7055 quote_ident(&table.primary_key)
7056 );
7057 self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
7058 for index in &table.indexes {
7062 let unique = if index.unique { "UNIQUE " } else { "" };
7063 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
7064 let cols_sql = index
7065 .columns
7066 .iter()
7067 .map(|c| quote_ident(c))
7068 .collect::<Vec<_>>()
7069 .join(", ");
7070 let index_sql = format!(
7071 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
7072 );
7073 self.conn
7074 .execute(&index_sql, [])
7075 .map_err(|e| e.to_string())?;
7076 }
7077 }
7078 }
7079 for table in &self.schema.tables {
7082 for index in &table.fts_indexes {
7083 self.create_fts_projection(table, index)?;
7084 }
7085 }
7086 Ok(())
7087 }
7088
7089 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
7090 let count: i64 = self
7091 .conn
7092 .query_row(
7093 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
7094 rusqlite::params![index.name],
7095 |row| row.get(0),
7096 )
7097 .map_err(|e| e.to_string())?;
7098 Ok(count > 0)
7099 }
7100
7101 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
7102 for suffix in ["ai", "ad", "au"] {
7103 self.conn
7104 .execute(
7105 &format!(
7106 "DROP TRIGGER IF EXISTS {}",
7107 quote_ident(&format!("{}_{suffix}", index.name))
7108 ),
7109 [],
7110 )
7111 .map_err(|e| e.to_string())?;
7112 }
7113 Ok(())
7114 }
7115
7116 fn create_fts_triggers(
7117 &self,
7118 table: &TableSchema,
7119 index: &FtsIndexSchema,
7120 ) -> Result<(), String> {
7121 let fts = quote_ident(&index.name);
7122 let source = visible_table(&table.name);
7123 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7124 let pk = quote_ident(&table.primary_key);
7125 let projection_columns = std::iter::once(source_id.clone())
7126 .chain(index.columns.iter().map(|column| quote_ident(column)))
7127 .collect::<Vec<_>>()
7128 .join(", ");
7129 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
7130 .chain(
7131 index
7132 .columns
7133 .iter()
7134 .map(|column| format!("new.{}", quote_ident(column))),
7135 )
7136 .collect::<Vec<_>>()
7137 .join(", ");
7138 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7139 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)");
7140 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)");
7141 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})");
7142 let bi = quote_ident(&format!("{}_bi", index.name));
7143 let ai = quote_ident(&format!("{}_ai", index.name));
7144 let ad = quote_ident(&format!("{}_ad", index.name));
7145 let au = quote_ident(&format!("{}_au", index.name));
7146 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
7147 let sql = format!(
7148 "DROP TRIGGER IF EXISTS {bi};
7149 DROP TRIGGER IF EXISTS {ai};
7150 DROP TRIGGER IF EXISTS {ad};
7151 DROP TRIGGER IF EXISTS {au};
7152 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
7153 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
7154 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
7155 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
7156 );
7157 self.conn.execute_batch(&sql).map_err(|e| e.to_string())
7158 }
7159
7160 fn rebuild_fts_projection(
7161 &self,
7162 table: &TableSchema,
7163 index: &FtsIndexSchema,
7164 ) -> Result<(), String> {
7165 let fts = quote_ident(&index.name);
7166 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7167 let pk = quote_ident(&table.primary_key);
7168 let indexed_columns = index
7169 .columns
7170 .iter()
7171 .map(|column| quote_ident(column))
7172 .collect::<Vec<_>>();
7173 let projection_columns = std::iter::once(source_id.clone())
7174 .chain(indexed_columns.iter().cloned())
7175 .collect::<Vec<_>>()
7176 .join(", ");
7177 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7178 let sql = format!(
7179 "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};",
7180 columns = indexed_columns.join(", "),
7181 source = visible_table(&table.name),
7182 );
7183 self.conn.execute_batch(&sql).map_err(|e| e.to_string())
7184 }
7185
7186 fn create_fts_projection(
7187 &self,
7188 table: &TableSchema,
7189 index: &FtsIndexSchema,
7190 ) -> Result<(), String> {
7191 self.begin_observation("syncular_fts_schema")?;
7192 let result = (|| {
7193 let existed = self.fts_projection_exists(index)?;
7194 let tokenizer = index.tokenize.replace('\'', "''");
7195 let columns = index
7196 .columns
7197 .iter()
7198 .map(|column| quote_ident(column))
7199 .collect::<Vec<_>>()
7200 .join(", ");
7201 let sql = format!(
7202 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
7203 fts = quote_ident(&index.name),
7204 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
7205 );
7206 self.conn.execute(&sql, []).map_err(|error| {
7207 format!(
7208 "cannot create local FTS5 projection {:?}: {error}",
7209 index.name
7210 )
7211 })?;
7212 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7213 let mapped: bool = self
7214 .conn
7215 .query_row(
7216 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
7217 [format!("_syncular_fts_{}", index.name)],
7218 |row| row.get(0),
7219 )
7220 .map_err(|e| e.to_string())?;
7221 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())?;
7222 if !mapped && existed {
7223 self.conn
7224 .execute_batch(&format!(
7225 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
7226 quote_ident(FTS_SOURCE_ID_COLUMN),
7227 quote_ident(&index.name)
7228 ))
7229 .map_err(|e| e.to_string())?;
7230 }
7231 self.create_fts_triggers(table, index)?;
7232 if !existed {
7233 self.rebuild_fts_projection(table, index)?;
7234 }
7235 Ok(())
7236 })();
7237 if let Err(error) = result {
7238 self.rollback_observation("syncular_fts_schema");
7239 return Err(error);
7240 }
7241 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
7242 self.rollback_observation("syncular_fts_schema");
7243 return Err(error.to_string());
7244 }
7245 Ok(())
7246 }
7247
7248 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
7251 let state = serde_json::json!({
7252 "requested": scope_map_to_json(&sub.requested),
7253 "params": sub.params,
7254 "cursor": sub.cursor,
7255 "bootstrapState": sub.bootstrap_state,
7256 "status": sub.state.name(),
7257 "reasonCode": sub.reason_code,
7258 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7259 "syncedOnce": sub.synced_once,
7260 });
7261 self.conn.execute(
7262 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
7263 rusqlite::params![sub.id, sub.table, state.to_string()],
7264 ).map(|_| ()).map_err(|error| error.to_string())
7265 }
7266
7267 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
7268 let ops: Vec<Value> = commit
7269 .ops
7270 .iter()
7271 .map(|op| {
7272 serde_json::json!({
7273 "op": if op.upsert { "upsert" } else { "delete" },
7274 "table": op.table,
7275 "rowId": op.row_id,
7276 "baseVersion": op.base_version,
7277 "values": op.values.clone().map(Value::Object),
7278 })
7279 })
7280 .collect();
7281 self.conn
7282 .execute(
7283 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
7284 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
7285 )
7286 .map_err(|error| error.to_string())?;
7287 for operation in &commit.ops {
7288 let Some(values) = &operation.values else {
7289 continue;
7290 };
7291 let Some(table) = self.schema.table(&operation.table) else {
7292 continue;
7293 };
7294 for column in table
7295 .columns
7296 .iter()
7297 .filter(|column| column.ty == ColumnType::BlobRef)
7298 {
7299 let Some(Value::String(raw)) = values.get(&column.name) else {
7300 continue;
7301 };
7302 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
7303 continue;
7304 };
7305 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
7306 continue;
7307 };
7308 self.conn
7309 .execute(
7310 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
7311 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
7312 rusqlite::params![commit.client_commit_id, blob_id],
7313 )
7314 .map_err(|error| error.to_string())?;
7315 }
7316 }
7317 Ok(())
7318 }
7319
7320 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
7321 self.conn
7322 .execute(
7323 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
7324 rusqlite::params![client_commit_id],
7325 )
7326 .map_err(|error| error.to_string())?;
7327 self.conn
7328 .execute(
7329 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
7330 rusqlite::params![client_commit_id],
7331 )
7332 .map(|_| ())
7333 .map_err(|error| error.to_string())
7334 }
7335
7336 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
7337 match status {
7338 CommitOutcomeStatus::Applied => "applied",
7339 CommitOutcomeStatus::Cached => "cached",
7340 CommitOutcomeStatus::Conflict => "conflict",
7341 CommitOutcomeStatus::Rejected => "rejected",
7342 }
7343 }
7344
7345 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
7346 match resolution {
7347 CommitOutcomeResolution::Active => "active",
7348 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
7349 CommitOutcomeResolution::Superseded => "superseded",
7350 CommitOutcomeResolution::Dismissed => "dismissed",
7351 }
7352 }
7353
7354 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
7355 match value {
7356 "applied" => Ok(CommitOutcomeStatus::Applied),
7357 "cached" => Ok(CommitOutcomeStatus::Cached),
7358 "conflict" => Ok(CommitOutcomeStatus::Conflict),
7359 "rejected" => Ok(CommitOutcomeStatus::Rejected),
7360 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
7361 }
7362 }
7363
7364 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
7365 match value {
7366 "active" => Ok(CommitOutcomeResolution::Active),
7367 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
7368 "superseded" => Ok(CommitOutcomeResolution::Superseded),
7369 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
7370 _ => Err(format!(
7371 "invalid persisted commit outcome resolution {value:?}"
7372 )),
7373 }
7374 }
7375
7376 fn persist_commit_outcome(
7377 &self,
7378 client_commit_id: &str,
7379 status: CommitOutcomeStatus,
7380 results: &[CommitOperationOutcome],
7381 operations: Option<&[OutboxOp]>,
7382 ) -> Result<(), String> {
7383 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
7384 let operations_json = operations
7385 .map(|items| {
7386 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
7387 })
7388 .transpose()
7389 .map_err(|error| error.to_string())?;
7390 self.conn
7391 .execute(
7392 "INSERT INTO _syncular_commit_outcomes (
7393 client_commit_id, status, recorded_at_ms, results_json,
7394 operations_json, resolution
7395 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
7396 rusqlite::params![
7397 client_commit_id,
7398 Self::outcome_status_name(status),
7399 self.clock_now_ms(),
7400 results_json,
7401 operations_json
7402 ],
7403 )
7404 .map(|_| ())
7405 .map_err(|error| error.to_string())
7406 }
7407
7408 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
7409 let StoredCommitOutcomeRow {
7410 sequence,
7411 client_commit_id,
7412 status,
7413 recorded_at_ms,
7414 results_json,
7415 operations_json,
7416 resolution,
7417 resolved_at_ms,
7418 replacement_client_commit_id,
7419 } = row;
7420 Ok(CommitOutcome {
7421 sequence,
7422 client_commit_id,
7423 status: Self::parse_outcome_status(&status)?,
7424 recorded_at_ms,
7425 results: serde_json::from_str(&results_json)
7426 .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
7427 operations: operations_json
7428 .map(|value| {
7429 serde_json::from_str(&value).map_err(|error| {
7430 format!("invalid persisted commit outcome operations: {error}")
7431 })
7432 })
7433 .transpose()?,
7434 resolution: Self::parse_outcome_resolution(&resolution)?,
7435 resolved_at_ms,
7436 replacement_client_commit_id,
7437 })
7438 }
7439
7440 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
7441 let row = self
7442 .conn
7443 .query_row(
7444 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7445 resolution, resolved_at_ms, replacement_client_commit_id
7446 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
7447 rusqlite::params![client_commit_id],
7448 |row| {
7449 Ok(StoredCommitOutcomeRow {
7450 sequence: row.get(0)?,
7451 client_commit_id: row.get(1)?,
7452 status: row.get(2)?,
7453 recorded_at_ms: row.get(3)?,
7454 results_json: row.get(4)?,
7455 operations_json: row.get(5)?,
7456 resolution: row.get(6)?,
7457 resolved_at_ms: row.get(7)?,
7458 replacement_client_commit_id: row.get(8)?,
7459 })
7460 },
7461 )
7462 .optional()
7463 .map_err(|error| error.to_string())?;
7464 row.map(Self::outcome_from_row).transpose()
7465 }
7466
7467 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
7468 if query.limit == Some(0) {
7469 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
7470 }
7471 let mut sql = String::from(
7472 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7473 resolution, resolved_at_ms, replacement_client_commit_id
7474 FROM _syncular_commit_outcomes",
7475 );
7476 if query.active_only {
7477 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
7478 }
7479 sql.push_str(" ORDER BY seq DESC");
7480 if let Some(limit) = query.limit {
7481 sql.push_str(&format!(" LIMIT {limit}"));
7482 }
7483 let mut stmt = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
7484 let rows = stmt
7485 .query_map([], |row| {
7486 Ok(StoredCommitOutcomeRow {
7487 sequence: row.get(0)?,
7488 client_commit_id: row.get(1)?,
7489 status: row.get(2)?,
7490 recorded_at_ms: row.get(3)?,
7491 results_json: row.get(4)?,
7492 operations_json: row.get(5)?,
7493 resolution: row.get(6)?,
7494 resolved_at_ms: row.get(7)?,
7495 replacement_client_commit_id: row.get(8)?,
7496 })
7497 })
7498 .map_err(|error| error.to_string())?;
7499 let mut outcomes = Vec::new();
7500 for row in rows {
7501 outcomes.push(Self::outcome_from_row(
7502 row.map_err(|error| error.to_string())?,
7503 )?);
7504 }
7505 Ok(outcomes)
7506 }
7507
7508 fn prune_commit_outcomes(&self) -> Result<(), String> {
7509 #[cfg(test)]
7510 self.outcome_prune_count
7511 .set(self.outcome_prune_count.get() + 1);
7512 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
7513 let count = self
7514 .conn
7515 .query_row(
7516 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
7517 [],
7518 |row| row.get::<_, i64>(0),
7519 )
7520 .map_err(|error| error.to_string())? as usize;
7521 let excess = count.saturating_sub(max_entries);
7522 if excess == 0 {
7523 return Ok(());
7524 }
7525 let mut stmt = self
7526 .conn
7527 .prepare(
7528 "SELECT seq FROM _syncular_commit_outcomes
7529 WHERE status IN ('applied', 'cached') OR resolution != 'active'
7530 ORDER BY seq ASC LIMIT ?1",
7531 )
7532 .map_err(|error| error.to_string())?;
7533 let rows = stmt
7534 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
7535 .map_err(|error| error.to_string())?;
7536 let sequences = rows
7537 .collect::<Result<Vec<_>, _>>()
7538 .map_err(|error| error.to_string())?;
7539 drop(stmt);
7540 for sequence in sequences {
7541 self.conn
7542 .execute(
7543 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
7544 rusqlite::params![sequence],
7545 )
7546 .map_err(|error| error.to_string())?;
7547 }
7548 Ok(())
7549 }
7550
7551 pub fn subscribe(
7554 &mut self,
7555 id: String,
7556 table: String,
7557 scopes: Vec<(String, Vec<String>)>,
7558 params: Option<String>,
7559 ) -> Result<(), String> {
7560 if self.schema.table(&table).is_none() {
7561 return Err(format!("unknown table {table:?}"));
7562 }
7563 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
7564 let same_intent = existing.table == table
7565 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
7566 && existing.params == params;
7567 if same_intent {
7568 return Ok(());
7569 }
7570 return Err(
7571 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
7572 .to_owned(),
7573 );
7574 }
7575 let sub = Subscription {
7576 id: id.clone(),
7577 table,
7578 requested: scopes,
7579 params,
7580 cursor: -1,
7581 bootstrap_state: None,
7582 state: SubState::Active,
7583 reason_code: None,
7584 effective: None,
7585 synced_once: false,
7586 };
7587 self.persist_sub(&sub)?;
7588 self.subs.push(sub);
7589 Ok(())
7590 }
7591
7592 pub fn unsubscribe(&mut self, id: &str) {
7593 self.subs.retain(|s| s.id != id);
7594 let _ = self.conn.execute(
7595 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7596 rusqlite::params![id],
7597 );
7598 }
7599
7600 pub fn set_window(
7608 &mut self,
7609 base: &WindowBase,
7610 units: &[String],
7611 ) -> Result<CommandEffects, String> {
7612 let table = self
7613 .schema
7614 .table(&base.table)
7615 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
7616 if table.scope_column(&base.variable).is_none() {
7617 return Err(format!(
7618 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
7619 base.table, base.variable
7620 ));
7621 }
7622 let base_key = window_base_key(base);
7623 let wanted: std::collections::HashSet<&String> = units.iter().collect();
7624 let live = self.load_window_units(&base_key);
7625 self.begin_observation("syncular_window")?;
7626 let mut batch = ChangeAccumulator::default();
7627 let mut changed = false;
7628
7629 let prior_subs = self.subs.clone();
7630 let widened = (|| {
7631 for unit in units {
7633 if live.iter().any(|(u, _)| u == unit) {
7634 continue;
7635 }
7636 let sub_id = derive_sub_id(base, unit);
7637 self.delete_pending_evict(&sub_id)?;
7638 self.insert_window_unit(&base_key, unit, &sub_id)?;
7639 self.subscribe(
7640 sub_id,
7641 base.table.clone(),
7642 unit_scopes(base, unit),
7643 base.params.clone(),
7644 )?;
7645 batch.window(&base_key, &base.table, unit);
7646 changed = true;
7647 }
7648
7649 self.finish_observation("syncular_window", batch)
7650 })();
7651 if let Err(error) = widened {
7652 self.rollback_observation("syncular_window");
7653 self.subs = prior_subs;
7654 return Err(error);
7655 }
7656 for (unit, sub_id) in live {
7657 if wanted.contains(&unit) {
7658 continue;
7659 }
7660 self.rebuild_overlay_if_dirty();
7661 let prior_subs = self.subs.clone();
7662 self.begin_observation("syncular_window")?;
7663 let effective = self
7664 .subs
7665 .iter()
7666 .find(|sub| sub.id == sub_id)
7667 .and_then(|sub| sub.effective.clone())
7668 .unwrap_or_else(|| unit_scopes(base, &unit));
7669 let mut batch = ChangeAccumulator::default();
7670 self.record_scope_map(&mut batch, &base.table, &effective);
7671 batch.window(&base_key, &base.table, &unit);
7672 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
7673 let remaining = match result {
7674 Ok(remaining) => remaining,
7675 Err(error) => {
7676 self.rollback_observation("syncular_window");
7677 self.subs = prior_subs;
7678 return Err(error);
7679 }
7680 };
7681 if let Err(error) = self.finish_observation("syncular_window", batch) {
7682 self.rollback_observation("syncular_window");
7683 self.subs = prior_subs;
7684 return Err(error);
7685 }
7686 if remaining {
7687 self.drain_pending_evictions()?;
7688 }
7689 changed = true;
7690 }
7691 Ok(if changed {
7692 CommandEffects::interactive()
7693 } else {
7694 CommandEffects::none()
7695 })
7696 }
7697
7698 pub fn window_state(&self, base: &WindowBase) -> WindowState {
7703 let mut units = Vec::new();
7704 let mut pending = Vec::new();
7705 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
7706 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
7707 Some(sub) => {
7708 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
7709 }
7710 None => true,
7711 };
7712 if is_pending {
7713 pending.push(unit.clone());
7714 }
7715 units.push(unit);
7716 }
7717 WindowState { units, pending }
7718 }
7719
7720 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
7721 let mut stmt = match self
7722 .conn
7723 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
7724 {
7725 Ok(stmt) => stmt,
7726 Err(_) => return Vec::new(),
7727 };
7728 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
7729 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7730 });
7731 match rows {
7732 Ok(rows) => rows.filter_map(Result::ok).collect(),
7733 Err(_) => Vec::new(),
7734 }
7735 }
7736
7737 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
7738 let mut stmt = match self.conn.prepare(
7739 "SELECT windows.base, windows.unit, subscriptions.tbl
7740 FROM _syncular_windows AS windows
7741 JOIN _syncular_subscriptions AS subscriptions
7742 ON subscriptions.id = windows.sub_id
7743 ORDER BY windows.base, windows.unit",
7744 ) {
7745 Ok(stmt) => stmt,
7746 Err(_) => return Vec::new(),
7747 };
7748 let rows = stmt.query_map([], |row| {
7749 Ok((
7750 row.get::<_, String>(0)?,
7751 row.get::<_, String>(1)?,
7752 row.get::<_, String>(2)?,
7753 ))
7754 });
7755 match rows {
7756 Ok(rows) => rows.filter_map(Result::ok).collect(),
7757 Err(_) => Vec::new(),
7758 }
7759 }
7760
7761 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
7762 self.conn
7763 .query_row(
7764 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
7765 rusqlite::params![sub_id],
7766 |row| Ok((row.get(0)?, row.get(1)?)),
7767 )
7768 .ok()
7769 }
7770
7771 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
7772 self.conn
7773 .execute(
7774 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
7775 rusqlite::params![base_key, unit, sub_id],
7776 )
7777 .map(|_| ())
7778 .map_err(|error| error.to_string())
7779 }
7780
7781 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
7782 self.conn
7783 .execute(
7784 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
7785 rusqlite::params![base_key, unit],
7786 )
7787 .map(|_| ())
7788 .map_err(|error| error.to_string())
7789 }
7790
7791 fn evict_unit(
7798 &mut self,
7799 base_key: &str,
7800 base: &WindowBase,
7801 unit: &str,
7802 sub_id: &str,
7803 ) -> Result<bool, String> {
7804 let effective = self
7805 .subs
7806 .iter()
7807 .find(|s| s.id == sub_id)
7808 .and_then(|s| s.effective.clone())
7809 .unwrap_or_else(|| unit_scopes(base, unit));
7810 let pinned = self.pinned_row_ids(&base.table);
7811 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
7812 self.delete_window_unit(base_key, unit)?;
7813 self.conn
7814 .execute(
7815 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7816 [sub_id],
7817 )
7818 .map_err(|error| error.to_string())?;
7819 self.subs.retain(|sub| sub.id != sub_id);
7820 if deferred {
7821 self.save_pending_evict(sub_id, &base.table, &effective)?;
7822 } else {
7823 self.delete_pending_evict(sub_id)?;
7824 }
7825 Ok(remaining)
7826 }
7827
7828 fn evict_scope_rows(
7832 &self,
7833 table_name: &str,
7834 effective: &[(String, Vec<String>)],
7835 pinned: &std::collections::HashSet<String>,
7836 ) -> Result<(bool, bool), String> {
7837 if effective.is_empty() {
7838 return Ok((false, false));
7839 }
7840 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
7841 let mut clauses = Vec::new();
7842 let mut params: Vec<SqlValue> = Vec::new();
7843 for (variable, values) in effective {
7844 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
7845 if values.is_empty() {
7846 return Ok((false, false));
7847 }
7848 let holes = values
7849 .iter()
7850 .map(|v| {
7851 params.push(SqlValue::Text(v.clone()));
7852 "?"
7853 })
7854 .collect::<Vec<_>>()
7855 .join(", ");
7856 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
7857 }
7858 let scope = clauses.join(" AND ");
7859 let scope_params = params.len();
7860 let pk = quote_ident(&table.primary_key);
7861 if !pinned.is_empty() {
7862 let holes = pinned
7863 .iter()
7864 .map(|id| {
7865 params.push(SqlValue::Text(id.clone()));
7866 "?"
7867 })
7868 .collect::<Vec<_>>()
7869 .join(", ");
7870 clauses.push(format!("{pk} NOT IN ({holes})"));
7871 }
7872 let predicate = clauses.join(" AND ");
7873 let base = base_table(table_name);
7874 let visible = visible_table(table_name);
7875 let mut ids: Vec<SqlValue> = Vec::new();
7876 for source in [&base, &visible] {
7877 let mut stmt = self
7878 .conn
7879 .prepare_cached(&format!(
7880 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
7881 ))
7882 .map_err(|e| e.to_string())?;
7883 ids = stmt
7884 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
7885 .map_err(|e| e.to_string())?
7886 .collect::<Result<_, _>>()
7887 .map_err(|e| e.to_string())?;
7888 if !ids.is_empty() {
7889 break;
7890 }
7891 }
7892 if !ids.is_empty() {
7893 let holes = vec!["?"; ids.len()].join(", ");
7894 for source in [&base, &visible] {
7895 self.conn
7896 .execute(
7897 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
7898 rusqlite::params_from_iter(&ids),
7899 )
7900 .map_err(|e| e.to_string())?;
7901 }
7902 }
7903 let mut remaining = false;
7904 let mut deferred = false;
7905 for source in [&base, &visible] {
7906 remaining |= self
7907 .conn
7908 .query_row(
7909 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
7910 rusqlite::params_from_iter(¶ms),
7911 |row| row.get::<_, bool>(0),
7912 )
7913 .map_err(|e| e.to_string())?;
7914 deferred |= self
7915 .conn
7916 .query_row(
7917 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
7918 rusqlite::params_from_iter(¶ms[..scope_params]),
7919 |row| row.get::<_, bool>(0),
7920 )
7921 .map_err(|e| e.to_string())?;
7922 }
7923 Ok((remaining, deferred))
7924 }
7925
7926 fn drain_pending_evictions(&mut self) -> Result<(), String> {
7928 let pending = self.load_pending_evictions()?;
7929 if pending.is_empty() {
7930 return Ok(());
7931 }
7932 self.rebuild_overlay_if_dirty();
7933 for (sub_id, table_name, effective) in pending {
7934 if self.schema.table(&table_name).is_none() {
7935 self.delete_pending_evict(&sub_id)?;
7936 continue;
7937 }
7938 loop {
7939 std::thread::yield_now();
7940 self.begin_observation("syncular_evict")?;
7941 let pinned = self.pinned_row_ids(&table_name);
7942 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
7943 let (remaining, deferred) = match result {
7944 Ok(result) => result,
7945 Err(error) => {
7946 self.rollback_observation("syncular_evict");
7947 return Err(error);
7948 }
7949 };
7950 if !deferred {
7951 if let Err(error) = self.delete_pending_evict(&sub_id) {
7952 self.rollback_observation("syncular_evict");
7953 return Err(error);
7954 }
7955 }
7956 let mut batch = ChangeAccumulator::default();
7957 self.record_scope_map(&mut batch, &table_name, &effective);
7958 if let Err(error) = self.finish_observation("syncular_evict", batch) {
7959 self.rollback_observation("syncular_evict");
7960 return Err(error);
7961 }
7962 if !remaining {
7963 break;
7964 }
7965 }
7966 }
7967 Ok(())
7968 }
7969
7970 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
7973 let mut pinned = std::collections::HashSet::new();
7974 for commit in &self.outbox {
7975 for op in &commit.ops {
7976 if op.table == table {
7977 pinned.insert(op.row_id.clone());
7978 }
7979 }
7980 }
7981 pinned
7982 }
7983
7984 fn save_pending_evict(
7985 &self,
7986 sub_id: &str,
7987 table: &str,
7988 effective: &[(String, Vec<String>)],
7989 ) -> Result<(), String> {
7990 self.conn.execute(
7991 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
7992 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
7993 ).map(|_| ()).map_err(|error| error.to_string())
7994 }
7995
7996 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
7997 self.conn
7998 .execute(
7999 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
8000 rusqlite::params![sub_id],
8001 )
8002 .map(|_| ())
8003 .map_err(|error| error.to_string())
8004 }
8005
8006 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
8007 let mut stmt = self
8008 .conn
8009 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
8010 .map_err(|error| error.to_string())?;
8011 let rows = stmt
8012 .query_map([], |row| {
8013 Ok((
8014 row.get::<_, String>(0)?,
8015 row.get::<_, String>(1)?,
8016 row.get::<_, String>(2)?,
8017 ))
8018 })
8019 .map_err(|error| error.to_string())?;
8020 rows.map(|row| {
8021 let (sub_id, table, json) = row.map_err(|error| error.to_string())?;
8022 let value: Value = serde_json::from_str(&json)
8023 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8024 let effective = json_to_scope_map(&value)
8025 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8026 Ok((sub_id, table, effective))
8027 })
8028 .collect()
8029 }
8030
8031 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
8033 if mutations.is_empty() {
8034 return Err("a commit must contain at least one operation (§6.1)".to_owned());
8035 }
8036 let mut ops = Vec::with_capacity(mutations.len());
8037 for mutation in mutations {
8038 match mutation {
8039 Mutation::Upsert {
8040 table,
8041 values,
8042 base_version,
8043 } => {
8044 let schema_table = self
8045 .schema
8046 .table(&table)
8047 .ok_or_else(|| format!("unknown table {table:?}"))?;
8048 let values = normalize_values_casing(schema_table, values)?;
8052 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
8053 let values = full_row_values(schema_table, values)?;
8056 self.validate_author_encode(schema_table, &row_id, &values)?;
8062 ops.push(OutboxOp {
8063 upsert: true,
8064 table,
8065 row_id,
8066 base_version,
8067 values: Some(values),
8068 });
8069 }
8070 Mutation::Delete {
8071 table,
8072 row_id,
8073 base_version,
8074 } => {
8075 if self.schema.table(&table).is_none() {
8076 return Err(format!("unknown table {table:?}"));
8077 }
8078 ops.push(OutboxOp {
8079 upsert: false,
8080 table,
8081 row_id,
8082 base_version,
8083 values: None,
8084 });
8085 }
8086 }
8087 }
8088 self.record_outbox_commit(ops)
8089 }
8090
8091 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
8092 let commit = OutboxCommit {
8093 client_commit_id: uuid::Uuid::new_v4().to_string(),
8094 ops,
8095 };
8096 self.begin_observation("syncular_mutation")?;
8097 let mut batch = ChangeAccumulator::default();
8098 for op in &commit.ops {
8099 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
8100 if let Some(values) = &op.values {
8101 if let Some(table) = self.schema.table(&op.table) {
8102 for scope in &table.scope_variables {
8103 if let Some(Value::String(value)) = values.get(&scope.column) {
8104 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
8105 precise = true;
8106 }
8107 }
8108 }
8109 }
8110 if !precise {
8111 batch.table(&op.table);
8112 }
8113 }
8114 if let Err(error) = self.persist_outbox_insert(&commit) {
8115 self.rollback_observation("syncular_mutation");
8116 return Err(error);
8117 }
8118 let was_dirty = self.overlay_dirty.get();
8119 if was_dirty {
8124 self.rebuild_overlay();
8125 }
8126 if let Some(error) = commit
8127 .ops
8128 .iter()
8129 .find_map(|op| self.apply_outbox_op(op).err())
8130 {
8131 self.rollback_observation("syncular_mutation");
8132 self.overlay_dirty.set(was_dirty);
8133 return Err(error);
8134 }
8135 let id = commit.client_commit_id.clone();
8136 self.outbox.push(commit);
8137 batch.status = true;
8138 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
8139 self.rollback_observation("syncular_mutation");
8140 self.outbox.pop();
8141 self.overlay_dirty.set(was_dirty);
8142 return Err(error);
8143 }
8144 Ok(id)
8145 }
8146
8147 pub fn patch(
8153 &mut self,
8154 table: &str,
8155 row_id: &str,
8156 partial: Map<String, Value>,
8157 base_version: Option<i64>,
8158 ) -> Result<String, String> {
8159 let schema_table = self
8160 .schema
8161 .table(table)
8162 .ok_or_else(|| format!("unknown table {table:?}"))?;
8163 let partial = normalize_values_casing(schema_table, partial)?;
8164 if let Some(pk) = partial.get(&schema_table.primary_key) {
8165 if pk.as_str() != Some(row_id) {
8166 return Err(format!(
8167 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
8168 ));
8169 }
8170 }
8171 for scope in &schema_table.scope_variables {
8181 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
8182 continue;
8183 }
8184 let Some(column) = schema_table
8185 .columns
8186 .iter()
8187 .find(|column| column.name == scope.column)
8188 else {
8189 continue;
8190 };
8191 let stored = self
8192 .conn
8193 .query_row(
8194 &format!(
8195 "SELECT {} FROM {} WHERE {}",
8196 quote_ident(&scope.column),
8197 visible_table(&schema_table.name),
8198 row_id_predicate(schema_table)
8199 ),
8200 rusqlite::params![row_id],
8201 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
8202 )
8203 .ok();
8204 let incoming = json_to_column_value(column, partial.get(&scope.column))
8209 .ok()
8210 .map(|value| column_value_to_json(&value));
8211 let equal = stored.as_ref().is_some_and(|stored| {
8215 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
8216 });
8217 if !equal {
8218 return Err(format!(
8219 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
8220 scope.column
8221 ));
8222 }
8223 }
8224 let mut values = partial;
8225 for scope in &schema_table.scope_variables {
8228 values.remove(&scope.column);
8229 }
8230 values.insert(
8231 schema_table.primary_key.clone(),
8232 Value::from(row_id.to_owned()),
8233 );
8234 self.validate_author_encode(schema_table, row_id, &values)?;
8240 self.record_outbox_commit(vec![OutboxOp {
8241 upsert: true,
8242 table: table.to_owned(),
8243 row_id: row_id.to_owned(),
8244 base_version,
8245 values: Some(values),
8246 }])
8247 }
8248
8249 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
8254 let selector = self.encryption.key_id_columns.get(&table.name)?;
8255 let index = table
8256 .columns
8257 .iter()
8258 .position(|column| &column.name == selector)?;
8259 let sql = format!(
8260 "SELECT {} FROM {} WHERE {}",
8261 quote_ident(selector),
8262 visible_table(&table.name),
8263 row_id_predicate(table)
8264 );
8265 let key: Option<String> = self
8266 .conn
8267 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
8268 .ok()?;
8269 let key = key.filter(|key| !key.is_empty())?;
8270 let mut fallback: Row = vec![None; table.columns.len()];
8271 fallback[index] = Some(ColumnValue::String(key));
8272 Some(fallback)
8273 }
8274
8275 fn validate_author_encode(
8280 &self,
8281 table: &TableSchema,
8282 row_id: &str,
8283 values: &Map<String, Value>,
8284 ) -> Result<(), String> {
8285 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
8286 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
8287 result => result.map(|_| ()),
8288 }
8289 }
8290
8291 fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
8297 if !self
8298 .schema
8299 .tables
8300 .iter()
8301 .any(|table| table.has_encrypted_columns())
8302 {
8303 return Ok(false);
8304 }
8305 let failures = self
8306 .outbox
8307 .iter()
8308 .filter_map(|commit| {
8309 commit
8310 .ops
8311 .iter()
8312 .find_map(|op| {
8313 let values = op.values.as_ref().filter(|_| op.upsert)?;
8314 let table = self.schema.table(&op.table)?;
8315 let fallback = self.stored_key_fallback(table, &op.row_id);
8316 match encode_sparse_row_json(
8317 table,
8318 &op.row_id,
8319 values,
8320 &self.encryption,
8321 fallback.as_ref(),
8322 ) {
8323 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
8324 _ => None,
8325 }
8326 })
8327 .map(|error| (commit.clone(), error))
8328 })
8329 .collect::<Vec<_>>();
8330 if failures.is_empty() {
8331 return Ok(false);
8332 }
8333 for (commit, message) in failures {
8334 let rejection = RejectionRecord {
8335 client_commit_id: commit.client_commit_id.clone(),
8336 op_index: 0,
8337 code: ENCRYPT_FAILED_CODE.to_owned(),
8338 message,
8339 retryable: false,
8340 details: None,
8341 operation: commit.ops.first().map(CommitOperation::from),
8342 };
8343 self.persist_commit_outcome(
8344 &commit.client_commit_id,
8345 CommitOutcomeStatus::Rejected,
8346 &[CommitOperationOutcome::Error {
8347 rejection: rejection.clone(),
8348 }],
8349 Some(&commit.ops),
8350 )?;
8351 self.delete_outbox_persisted(&commit.client_commit_id)?;
8352 self.outbox
8353 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
8354 self.rejections.push(rejection);
8355 }
8356 self.prune_commit_outcomes()?;
8357 self.overlay_dirty.set(true);
8358 self.rebuild_overlay();
8359 Ok(true)
8360 }
8361
8362 pub fn pending_commit_ids(&self) -> Vec<String> {
8363 self.outbox
8364 .iter()
8365 .map(|c| c.client_commit_id.clone())
8366 .collect()
8367 }
8368
8369 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
8373 let mut payloads = Vec::new();
8374 for commit in &self.outbox {
8375 for op in &commit.ops {
8376 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
8377 continue;
8378 };
8379 let Some(table) = self.schema.table(&op.table) else {
8380 continue;
8381 };
8382 let fallback = self.stored_key_fallback(table, &op.row_id);
8383 if let Ok(payload) = encode_sparse_row_json(
8384 table,
8385 &op.row_id,
8386 values,
8387 &self.encryption,
8388 fallback.as_ref(),
8389 ) {
8390 payloads.push(payload);
8391 }
8392 }
8393 }
8394 payloads
8395 }
8396
8397 pub fn conflicts(&self) -> &[ConflictRecord] {
8398 &self.conflicts
8399 }
8400
8401 pub fn rejections(&self) -> &[RejectionRecord] {
8402 &self.rejections
8403 }
8404
8405 pub fn resolve_commit_outcome(
8406 &mut self,
8407 input: ResolveCommitOutcomeInput,
8408 ) -> Result<CommitOutcome, String> {
8409 let current = self
8410 .commit_outcome(&input.client_commit_id)?
8411 .ok_or_else(|| {
8412 format!(
8413 "sync.outcome_not_found: no durable outcome exists for {:?}",
8414 input.client_commit_id
8415 )
8416 })?;
8417 if current.resolution != CommitOutcomeResolution::Active {
8418 return Ok(current);
8419 }
8420 if input.resolution == CommitOutcomeResolution::Active {
8421 return Err("sync.invalid_request: resolution must leave active state".to_owned());
8422 }
8423 match input.resolution {
8424 CommitOutcomeResolution::Superseded => {
8425 let replacement = input
8426 .replacement_client_commit_id
8427 .as_deref()
8428 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
8429 .ok_or_else(|| {
8430 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
8431 .to_owned()
8432 })?;
8433 let _ = replacement;
8434 }
8435 _ if input.replacement_client_commit_id.is_some() => {
8436 return Err(
8437 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
8438 .to_owned(),
8439 );
8440 }
8441 _ => {}
8442 }
8443 let allowed = match current.status {
8444 CommitOutcomeStatus::Conflict => matches!(
8445 input.resolution,
8446 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
8447 ),
8448 CommitOutcomeStatus::Rejected => {
8449 input.resolution == CommitOutcomeResolution::Superseded
8450 }
8451 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
8452 input.resolution == CommitOutcomeResolution::Dismissed
8453 }
8454 };
8455 if !allowed {
8456 return Err(format!(
8457 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
8458 input.resolution, current.status
8459 ));
8460 }
8461
8462 self.begin_observation("syncular_outcome_resolution")?;
8463 let result = (|| {
8464 self.conn
8465 .execute(
8466 "UPDATE _syncular_commit_outcomes
8467 SET resolution = ?1, resolved_at_ms = ?2,
8468 replacement_client_commit_id = ?3
8469 WHERE client_commit_id = ?4 AND resolution = 'active'",
8470 rusqlite::params![
8471 Self::outcome_resolution_name(input.resolution),
8472 self.clock_now_ms(),
8473 input.replacement_client_commit_id,
8474 input.client_commit_id
8475 ],
8476 )
8477 .map_err(|error| error.to_string())?;
8478 let resolved = self
8479 .commit_outcome(¤t.client_commit_id)?
8480 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
8481 self.prune_commit_outcomes()?;
8482 Ok(resolved)
8483 })();
8484 let resolved = match result {
8485 Ok(outcome) => outcome,
8486 Err(error) => {
8487 self.rollback_observation("syncular_outcome_resolution");
8488 return Err(error);
8489 }
8490 };
8491 self.conflicts
8492 .retain(|record| record.client_commit_id != current.client_commit_id);
8493 self.rejections
8494 .retain(|record| record.client_commit_id != current.client_commit_id);
8495 let batch = ChangeAccumulator {
8496 conflicts: current.status == CommitOutcomeStatus::Conflict,
8497 rejections: current.status == CommitOutcomeStatus::Rejected,
8498 outcomes: true,
8499 ..ChangeAccumulator::default()
8500 };
8501 if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
8502 self.rollback_observation("syncular_outcome_resolution");
8503 return Err(error);
8504 }
8505 Ok(resolved)
8506 }
8507
8508 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
8509 self.schema_floor.as_ref()
8510 }
8511
8512 pub fn lease_state(&self) -> Option<&LeaseState> {
8514 self.lease_state.as_ref()
8515 }
8516
8517 fn record_lease_error(&mut self, code: &str) {
8520 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
8521 return;
8522 }
8523 let mut next = self.lease_state.clone().unwrap_or_default();
8524 next.error_code = Some(code.to_owned());
8525 self.set_lease_state(Some(next));
8526 }
8527
8528 fn set_lease_state(&mut self, next: Option<LeaseState>) {
8529 if self.lease_state == next {
8530 return;
8531 }
8532 if self.begin_observation("syncular_lease").is_err() {
8533 return;
8534 }
8535 self.lease_state = next;
8536 if let Some(lease) = &self.lease_state {
8537 if let Ok(json) = serde_json::to_string(lease) {
8538 self.set_meta(LEASE_STATE_KEY, &json);
8539 }
8540 } else {
8541 self.delete_meta(LEASE_STATE_KEY);
8542 }
8543 let batch = ChangeAccumulator {
8544 status: true,
8545 ..ChangeAccumulator::default()
8546 };
8547 if self.finish_observation("syncular_lease", batch).is_err() {
8548 self.rollback_observation("syncular_lease");
8549 }
8550 }
8551
8552 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
8553 if self.schema_floor == next {
8554 return;
8555 }
8556 if self.begin_observation("syncular_schema_floor").is_err() {
8557 return;
8558 }
8559 self.schema_floor = next;
8560 self.stopped = self.schema_floor.is_some();
8561 if let Some(floor) = &self.schema_floor {
8562 if let Ok(json) = serde_json::to_string(floor) {
8563 self.set_meta(SCHEMA_FLOOR_KEY, &json);
8564 }
8565 } else {
8566 self.delete_meta(SCHEMA_FLOOR_KEY);
8567 }
8568 let batch = ChangeAccumulator {
8569 status: true,
8570 ..ChangeAccumulator::default()
8571 };
8572 if self
8573 .finish_observation("syncular_schema_floor", batch)
8574 .is_err()
8575 {
8576 self.rollback_observation("syncular_schema_floor");
8577 }
8578 }
8579
8580 fn set_upgrading(&mut self, value: bool) {
8581 if self.upgrading == value {
8582 return;
8583 }
8584 if self.begin_observation("syncular_upgrading").is_err() {
8585 return;
8586 }
8587 self.upgrading = value;
8588 let batch = ChangeAccumulator {
8589 status: true,
8590 ..ChangeAccumulator::default()
8591 };
8592 if self
8593 .finish_observation("syncular_upgrading", batch)
8594 .is_err()
8595 {
8596 self.rollback_observation("syncular_upgrading");
8597 }
8598 }
8599
8600 pub fn sync_needed(&self) -> bool {
8601 self.sync_needed
8602 }
8603
8604 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
8605 let sub = self.subs.iter().find(|s| s.id == id)?;
8606 Some(SubscriptionStateView {
8607 id: sub.id.clone(),
8608 table: sub.table.clone(),
8609 status: sub.state.name().to_owned(),
8610 cursor: sub.cursor,
8611 has_resume_token: sub.bootstrap_state.is_some(),
8612 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
8613 reason_code: sub.reason_code.clone(),
8614 })
8615 }
8616
8617 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
8618 let schema_table = self
8619 .schema
8620 .table(table)
8621 .ok_or_else(|| format!("unknown table {table:?}"))?;
8622 let sql = format!(
8623 "SELECT * FROM {} ORDER BY {} ASC",
8624 visible_table(table),
8625 quote_ident(&schema_table.primary_key)
8626 );
8627 let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
8628 let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
8629 let mut out = Vec::new();
8630 while let Some(row) = rows.next().map_err(|e| e.to_string())? {
8631 let mut values = Map::new();
8632 for (i, column) in schema_table.columns.iter().enumerate() {
8633 let value = row.get_ref(i).map_err(|e| e.to_string())?;
8634 values.insert(column.name.clone(), sql_ref_to_json(column, value));
8635 }
8636 let version: i64 = row
8637 .get(schema_table.columns.len())
8638 .map_err(|e| e.to_string())?;
8639 let row_id = match values.get(&schema_table.primary_key) {
8640 Some(Value::String(s)) => s.clone(),
8641 Some(Value::Number(n)) => n.to_string(),
8642 Some(Value::Bool(b)) => b.to_string(),
8643 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
8644 };
8645 out.push(RowState {
8646 row_id,
8647 version,
8648 values,
8649 });
8650 }
8651 Ok(out)
8652 }
8653
8654 #[cfg(feature = "crdt-yjs")]
8670 fn crdt_column_bytes(
8671 &self,
8672 table: &str,
8673 row_id: &str,
8674 column: &str,
8675 ) -> Result<Option<Vec<u8>>, String> {
8676 let schema_table = self
8677 .schema
8678 .table(table)
8679 .ok_or_else(|| format!("unknown table {table:?}"))?;
8680 let col = schema_table
8681 .columns
8682 .iter()
8683 .find(|c| c.name == column)
8684 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
8685 if col.ty != ColumnType::Crdt {
8686 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
8687 }
8688 let sql = format!(
8689 "SELECT {} FROM {} WHERE {}",
8690 quote_ident(column),
8691 visible_table(table),
8692 row_id_predicate(schema_table)
8693 );
8694 let bytes: Option<Vec<u8>> = self
8695 .conn
8696 .prepare_cached(&sql)
8697 .map_err(|error| error.to_string())?
8698 .query_row(rusqlite::params![row_id], |row| {
8699 row.get::<_, Option<Vec<u8>>>(0)
8700 })
8701 .map_err(|e| match e {
8702 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
8703 other => other.to_string(),
8704 })?;
8705 Ok(bytes)
8706 }
8707
8708 #[cfg(feature = "crdt-yjs")]
8712 pub fn crdt_text(
8713 &self,
8714 table: &str,
8715 row_id: &str,
8716 column: &str,
8717 name: &str,
8718 ) -> Result<String, String> {
8719 let bytes = self
8720 .crdt_column_bytes(table, row_id, column)?
8721 .unwrap_or_default();
8722 crate::crdt::text(&bytes, name)
8723 }
8724
8725 #[cfg(feature = "crdt-yjs")]
8729 pub fn crdt_insert_text(
8730 &mut self,
8731 table: &str,
8732 row_id: &str,
8733 column: &str,
8734 name: &str,
8735 index: u32,
8736 value: &str,
8737 ) -> Result<String, String> {
8738 let current = self
8739 .crdt_column_bytes(table, row_id, column)?
8740 .unwrap_or_default();
8741 let update = crate::crdt::insert_text(¤t, name, index, value)?;
8742 self.crdt_push_update(table, row_id, column, &update)
8743 }
8744
8745 #[cfg(feature = "crdt-yjs")]
8749 pub fn crdt_delete_text(
8750 &mut self,
8751 table: &str,
8752 row_id: &str,
8753 column: &str,
8754 name: &str,
8755 index: u32,
8756 len: u32,
8757 ) -> Result<String, String> {
8758 let current = self
8759 .crdt_column_bytes(table, row_id, column)?
8760 .unwrap_or_default();
8761 let update = crate::crdt::delete_text(¤t, name, index, len)?;
8762 self.crdt_push_update(table, row_id, column, &update)
8763 }
8764
8765 #[cfg(feature = "crdt-yjs")]
8770 pub fn crdt_apply_update(
8771 &mut self,
8772 table: &str,
8773 row_id: &str,
8774 column: &str,
8775 update: &[u8],
8776 ) -> Result<String, String> {
8777 let current = self
8778 .crdt_column_bytes(table, row_id, column)?
8779 .unwrap_or_default();
8780 let next = crate::crdt::apply_update(¤t, update)?;
8781 self.crdt_push_update(table, row_id, column, &next)
8782 }
8783
8784 #[cfg(feature = "crdt-yjs")]
8791 fn crdt_push_update(
8792 &mut self,
8793 table: &str,
8794 row_id: &str,
8795 column: &str,
8796 crdt_bytes: &[u8],
8797 ) -> Result<String, String> {
8798 let mut bytes_obj = Map::new();
8799 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
8800 let mut partial = Map::new();
8801 partial.insert(column.to_owned(), Value::Object(bytes_obj));
8802 self.patch(table, row_id, partial, None)
8803 }
8804
8805 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
8820 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
8821 }
8822
8823 #[cfg(feature = "bench-internals")]
8825 #[doc(hidden)]
8826 pub fn benchmark_connection(&mut self) -> &mut Connection {
8827 &mut self.conn
8828 }
8829
8830 #[cfg(feature = "bench-internals")]
8832 #[doc(hidden)]
8833 pub fn benchmark_phases(
8834 &self,
8835 enabled: Option<bool>,
8836 reset: bool,
8837 ) -> Result<Option<Value>, String> {
8838 self.benchmark_phases.configure(enabled, reset)?;
8839 Ok(self.benchmark_phases.snapshot())
8840 }
8841
8842 pub fn query_snapshot(
8846 &mut self,
8847 sql: &str,
8848 params: &[QueryValue],
8849 coverage: &[WindowCoverage],
8850 owner: Option<QueryOwner<'_>>,
8851 ) -> Result<QuerySnapshot, String> {
8852 let result = snapshot_connection(&self.conn, sql, params, coverage);
8853 if let Some(owner) = owner {
8854 self.record_query_read(owner, result.as_ref().err());
8855 }
8856 result.map_err(|failure| failure.to_string())
8857 }
8858
8859 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
8863 let position = self
8864 .query_failures
8865 .iter()
8866 .position(|entry| entry.id == owner.id);
8867 let Some(failure) = failure else {
8868 if let Some(index) = position {
8869 self.query_failures.remove(index);
8870 }
8871 return;
8872 };
8873 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
8874 .into_iter()
8875 .map(str::to_owned)
8876 .collect();
8877 let code = failure.code.unwrap_or("client.query_failed").to_owned();
8878 if position.is_some_and(|index| {
8879 let entry = &self.query_failures[index];
8880 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
8881 }) {
8882 return;
8883 }
8884 if let Some(index) = position {
8885 self.query_failures.remove(index);
8886 }
8887 self.query_failures.push(DiagnosticQueryFailure {
8888 id: owner.id.to_owned(),
8889 tables,
8890 code,
8891 sqlite_code: failure.sqlite_code,
8892 at_ms: self.clock_now_ms(),
8893 });
8894 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
8895 self.query_failures.remove(0);
8896 }
8897 }
8898
8899 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
8902 #[cfg(feature = "bench-internals")]
8903 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
8904 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
8905 let mut frames = vec![Frame::ReqHeader {
8906 client_id: self.client_id.clone(),
8907 schema_version: self.schema.version,
8908 log_epoch: log_epoch.clone(),
8909 }];
8910 let mut pushed_ids = Vec::new();
8911 let mut ops_in_request = 0usize;
8912 let mut deferred_commits = 0usize;
8913 #[cfg(feature = "bench-internals")]
8914 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
8915 for (index, commit) in self
8916 .outbox
8917 .iter()
8918 .take(if log_epoch.is_some() {
8919 self.outbox.len()
8920 } else {
8921 0
8922 })
8923 .enumerate()
8924 {
8925 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
8930 deferred_commits = self.outbox.len() - index;
8931 break;
8932 }
8933 ops_in_request += commit.ops.len();
8934 let operations = commit
8935 .ops
8936 .iter()
8937 .map(|op| {
8938 let payload = op.values.as_ref().and_then(|values| {
8939 let table = self.schema.table(&op.table)?;
8940 let fallback = self.stored_key_fallback(table, &op.row_id);
8949 encode_sparse_row_json(
8950 table,
8951 &op.row_id,
8952 values,
8953 &self.encryption,
8954 fallback.as_ref(),
8955 )
8956 .ok()
8957 });
8958 ssp2::model::Operation {
8959 table: op.table.clone(),
8960 row_id: op.row_id.clone(),
8961 op: if op.upsert { Op::Upsert } else { Op::Delete },
8962 base_version: op.base_version,
8963 payload,
8964 }
8965 })
8966 .collect();
8967 frames.push(Frame::PushCommit {
8968 client_commit_id: commit.client_commit_id.clone(),
8969 operations,
8970 });
8971 pushed_ids.push(commit.client_commit_id.clone());
8972 }
8973 #[cfg(feature = "bench-internals")]
8974 drop(outbox_phase);
8975 let accept = self.limits.accept.unwrap_or(if url_capable {
8978 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
8979 } else {
8980 DEFAULT_ACCEPT
8981 });
8982 frames.push(Frame::PullHeader {
8983 limit_commits: self.limits.limit_commits.unwrap_or(0),
8984 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
8985 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
8986 accept,
8987 });
8988 let mut fresh = Vec::new();
8989 for sub in &self.subs {
8990 if sub.state != SubState::Active {
8991 continue;
8992 }
8993 let mut scopes = sub.requested.clone();
8994 sort_scope_map(&mut scopes);
8995 frames.push(Frame::Subscription {
8996 id: sub.id.clone(),
8997 table: sub.table.clone(),
8998 scopes,
8999 params: sub.params.clone().map(RawJson),
9000 cursor: sub.cursor,
9001 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
9002 });
9003 fresh.push((
9004 sub.id.clone(),
9005 sub.cursor < 0 && sub.bootstrap_state.is_none(),
9006 ));
9007 }
9008 let message = Message {
9009 wire_version: WIRE_VERSION,
9010 msg_kind: MsgKind::Request,
9011 frames,
9012 };
9013 (
9014 message,
9015 RequestMeta {
9016 pushed_ids,
9017 fresh,
9018 accept,
9019 deferred_commits,
9020 },
9021 )
9022 }
9023
9024 pub fn progress(&self) -> ProgressObserver {
9028 self.progress.clone()
9029 }
9030
9031 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9032 self.progress.start();
9033 self.round_retry_delay_ms = None;
9034 let started_at_ms = self.clock_now_ms();
9035 let outcome = self.sync_inner(transport);
9036 let retry_delay_ms = self.round_retry_delay_ms;
9037 self.progress.update(|p| match &outcome {
9038 SyncOutcome::Ok(report) if report.failed.is_empty() => {
9039 p.state = ProgressState::Complete
9040 }
9041 SyncOutcome::Ok(_) => {
9042 p.state = ProgressState::Failed;
9043 p.error_code = Some("sync.scope_revoked".into());
9044 }
9045 SyncOutcome::Failed { error_code, .. } => {
9046 p.state = ProgressState::Failed;
9047 p.error_code = Some(Self::diagnostic_code(error_code));
9048 p.retry_delay_ms = retry_delay_ms;
9049 }
9050 SyncOutcome::RealtimeUnavailable { .. } => {
9051 p.state = ProgressState::Failed;
9052 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
9053 p.retry_delay_ms = retry_delay_ms;
9054 }
9055 });
9056 let completed_at_ms = self.clock_now_ms();
9057 self.last_round = Some(match &outcome {
9058 SyncOutcome::Ok(report) => DiagnosticLastRound {
9059 status: "succeeded".to_owned(),
9060 started_at_ms,
9061 completed_at_ms,
9062 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9063 counters: Some(DiagnosticRoundCounters {
9064 pushed: report.pushed,
9065 applied: report.applied.len(),
9066 rejected: report.rejected.len(),
9067 retryable: report.retryable.len(),
9068 conflicts: report.conflicts,
9069 commits_applied: report.commits_applied,
9070 segment_rows_applied: report.segment_rows_applied,
9071 bootstrapping: report.bootstrapping.len(),
9072 resets: report.resets.len(),
9073 revoked: report.revoked.len(),
9074 failed: report.failed.len(),
9075 deferred_commits: report.deferred_commits,
9076 }),
9077 error_code: None,
9078 },
9079 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
9080 status: "failed".to_owned(),
9081 started_at_ms,
9082 completed_at_ms,
9083 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9084 counters: None,
9085 error_code: Some(Self::diagnostic_code(error_code)),
9086 },
9087 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
9088 status: "failed".to_owned(),
9089 started_at_ms,
9090 completed_at_ms,
9091 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9092 counters: None,
9093 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
9094 },
9095 });
9096 outcome
9097 }
9098
9099 fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9100 if self.stopped {
9101 return SyncOutcome::Ok(SyncReport {
9104 schema_floor: self.schema_floor.clone(),
9105 ..SyncReport::default()
9106 });
9107 }
9108 self.set_sync_needed(false, false);
9111 if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
9114 if let Err(TransportError { code, message, .. }) = self.flush_blob_uploads(transport) {
9115 if Self::retryable_failure_code(&code) {
9116 self.schedule_background_retry();
9117 }
9118 return SyncOutcome::Failed {
9119 error_code: code,
9120 message,
9121 };
9122 }
9123 }
9124 if let Err(message) = self.drain_pending_evictions() {
9125 return SyncOutcome::Failed {
9126 error_code: "storage.failed".into(),
9127 message,
9128 };
9129 }
9130 if let Err(message) = self.drop_unencodable_outbox() {
9134 return SyncOutcome::Failed {
9135 error_code: "storage.failed".into(),
9136 message,
9137 };
9138 }
9139 let (message, meta) = self.build_request(transport.supports_url_fetch());
9140 let request_bytes = {
9141 #[cfg(feature = "bench-internals")]
9142 let _phase = self.benchmark_phases.start(Phase::RequestEncode);
9143 encode_message(&message)
9144 };
9145 let round = if self.realtime_state == RealtimeState::Connected {
9152 transport.realtime_sync(&request_bytes)
9153 } else if self.realtime_policy == RealtimePolicy::Required {
9154 let retry_delay_ms = self.schedule_background_retry();
9155 self.realtime_retry_delay_ms = Some(retry_delay_ms);
9156 return SyncOutcome::RealtimeUnavailable {
9157 state: self.realtime_state,
9158 reason_code: self.realtime_reason_code.clone(),
9159 retry_delay_ms,
9160 };
9161 } else {
9162 transport.sync(&request_bytes)
9163 };
9164 let realtime_round = self.realtime_state == RealtimeState::Connected;
9165 let response_bytes = match round {
9166 Ok(bytes) => bytes,
9167 Err(TransportError { code, message, .. }) => {
9168 if realtime_round {
9172 self.set_realtime_state(RealtimeState::Lost, Some(&code));
9173 }
9174 self.record_lease_error(&code);
9177 if Self::retryable_failure_code(&code) {
9178 let retry_delay_ms = self.schedule_background_retry();
9179 if realtime_round {
9180 self.realtime_retry_delay_ms = Some(retry_delay_ms);
9181 }
9182 }
9183 return SyncOutcome::Failed {
9184 error_code: code,
9185 message,
9186 };
9187 }
9188 };
9189 #[cfg(feature = "bench-internals")]
9190 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
9191 let response = match decode_message(&response_bytes) {
9192 Ok(message) => message,
9193 Err(error) => {
9194 return SyncOutcome::Failed {
9197 error_code: error.code.as_str().to_owned(),
9198 message: error.detail,
9199 };
9200 }
9201 };
9202 #[cfg(feature = "bench-internals")]
9203 drop(decode_phase);
9204 if response.msg_kind != MsgKind::Response {
9205 return SyncOutcome::Failed {
9206 error_code: "sync.invalid_request".to_owned(),
9207 message: "expected a response message".to_owned(),
9208 };
9209 }
9210 let mut outcome = self.process_response(transport, response, &meta);
9211 if let SyncOutcome::Ok(report) = &mut outcome {
9212 report.deferred_commits = meta.deferred_commits;
9213 }
9214 match &outcome {
9215 SyncOutcome::Ok(_) => self.reset_background_retry(),
9216 SyncOutcome::Failed { error_code, .. } if Self::retryable_failure_code(error_code) => {
9217 self.schedule_background_retry();
9218 }
9219 SyncOutcome::Failed { .. } => {}
9220 SyncOutcome::RealtimeUnavailable { .. } => {}
9221 }
9222 if meta.deferred_commits > 0 {
9223 self.set_sync_needed(true, true);
9226 }
9227 self.previous_version_lifetime_check();
9230 outcome
9231 }
9232
9233 pub fn sync_until_idle(
9234 &mut self,
9235 transport: &mut dyn Transport,
9236 max_rounds: Option<u32>,
9237 ) -> SyncOutcome {
9238 let rounds = max_rounds.unwrap_or(20).max(1);
9239 let mut aggregate = SyncReport::default();
9240 let mut spent = 0;
9241 while spent < rounds {
9242 spent += 1;
9243 let before = self
9244 .subs
9245 .iter()
9246 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9247 .collect::<Vec<_>>();
9248 let outcome = self.sync(transport);
9249 match outcome {
9250 SyncOutcome::Failed {
9251 error_code,
9252 message,
9253 } => {
9254 return SyncOutcome::Failed {
9255 error_code,
9256 message,
9257 };
9258 }
9259 SyncOutcome::RealtimeUnavailable { .. } => return outcome,
9260 SyncOutcome::Ok(report) => {
9261 if max_rounds.is_none()
9262 && report.segment_rows_applied > 0
9263 && !report.bootstrapping.is_empty()
9264 && before
9265 != self
9266 .subs
9267 .iter()
9268 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9269 .collect::<Vec<_>>()
9270 {
9271 spent = 0;
9272 }
9273 aggregate.pushed += report.pushed;
9274 aggregate.applied.extend(report.applied.iter().cloned());
9275 aggregate.rejected.extend(report.rejected.iter().cloned());
9276 aggregate.retryable.extend(report.retryable.iter().cloned());
9277 aggregate.conflicts += report.conflicts;
9278 aggregate.commits_applied += report.commits_applied;
9279 aggregate.segment_rows_applied += report.segment_rows_applied;
9280 aggregate.bootstrapping = report.bootstrapping.clone();
9281 aggregate.resets.extend(report.resets.iter().cloned());
9282 aggregate.revoked.extend(report.revoked.iter().cloned());
9283 aggregate.failed.extend(report.failed.iter().cloned());
9284 aggregate.deferred_commits = report.deferred_commits;
9285 if report.schema_floor.is_some() {
9286 aggregate.schema_floor = report.schema_floor.clone();
9287 }
9288 let more = !report.bootstrapping.is_empty()
9294 || report.commits_applied > 0
9295 || report.segment_rows_applied > 0
9296 || !report.resets.is_empty()
9297 || self.sync_needed;
9298 if !more {
9299 return SyncOutcome::Ok(aggregate);
9300 }
9301 }
9302 }
9303 }
9304 SyncOutcome::Failed {
9305 error_code: "sync.invalid_request".into(),
9306 message: "sync did not reach idle within the round budget".into(),
9307 }
9308 }
9309
9310 fn process_response(
9311 &mut self,
9312 transport: &mut dyn Transport,
9313 response: Message,
9314 meta: &RequestMeta,
9315 ) -> SyncOutcome {
9316 #[cfg(feature = "bench-internals")]
9317 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
9318 let mut report = SyncReport {
9319 pushed: meta.pushed_ids.len() as u32,
9320 ..SyncReport::default()
9321 };
9322 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
9323 HashMap::new();
9324 let pushed_ids = meta
9325 .pushed_ids
9326 .iter()
9327 .map(String::as_str)
9328 .collect::<HashSet<_>>();
9329 let mut last_final_push_result_id: Option<String> = None;
9330 for frame in &response.frames {
9331 match frame {
9332 Frame::PushResultDetails {
9333 client_commit_id,
9334 entries,
9335 } => {
9336 let details = rejection_details_by_commit
9337 .entry(client_commit_id.clone())
9338 .or_default();
9339 for entry in entries {
9340 let parsed = match RejectionDetails::parse(&entry.details.0) {
9341 Ok(value) => value,
9342 Err(message) => {
9343 return SyncOutcome::Failed {
9344 error_code: "sync.invalid_request".to_owned(),
9345 message,
9346 };
9347 }
9348 };
9349 details.insert(entry.op_index, parsed);
9350 }
9351 }
9352 Frame::PushResult {
9353 client_commit_id,
9354 status,
9355 results,
9356 ..
9357 } if pushed_ids.contains(client_commit_id.as_str())
9358 && Self::push_result_is_final(*status, results) =>
9359 {
9360 last_final_push_result_id = Some(client_commit_id.clone());
9361 }
9362 _ => {}
9363 }
9364 }
9365 let mut frames = response.frames.into_iter().peekable();
9366 if response.wire_version < 2 {
9367 return SyncOutcome::Failed {
9368 error_code: "client.invalid_host_response".to_owned(),
9369 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
9370 };
9371 }
9372 match frames.next() {
9373 Some(Frame::RespHeader {
9374 required_schema_version,
9375 latest_schema_version,
9376 log_epoch,
9377 reset_required,
9378 }) => {
9379 let Some(log_epoch) = log_epoch else {
9380 return SyncOutcome::Failed {
9381 error_code: "client.invalid_host_response".to_owned(),
9382 message: "response header omits logEpoch".to_owned(),
9383 };
9384 };
9385 let Some(reset_required) = reset_required else {
9386 return SyncOutcome::Failed {
9387 error_code: "client.invalid_host_response".to_owned(),
9388 message: "response header omits resetRequired".to_owned(),
9389 };
9390 };
9391 if let Some(required) = required_schema_version {
9392 let floor = SchemaFloor {
9395 required_schema_version: Some(required),
9396 latest_schema_version,
9397 };
9398 self.set_schema_floor(Some(floor.clone()));
9399 report.schema_floor = Some(floor);
9400 return SyncOutcome::Ok(report);
9401 }
9402 if reset_required {
9403 if frames.next().is_some() {
9404 return SyncOutcome::Failed {
9405 error_code: "client.invalid_host_response".to_owned(),
9406 message: "log-epoch reset response contains body frames".to_owned(),
9407 };
9408 }
9409 match self.run_log_epoch_reset(&log_epoch) {
9410 Ok(resets) => {
9411 report.resets = resets;
9412 return SyncOutcome::Ok(report);
9413 }
9414 Err(message) => {
9415 return SyncOutcome::Failed {
9416 error_code: "sync.local_corrupt".to_owned(),
9417 message,
9418 };
9419 }
9420 }
9421 }
9422 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
9423 return SyncOutcome::Failed {
9424 error_code: "client.invalid_host_response".to_owned(),
9425 message: "server changed logEpoch without requiring a reset".to_owned(),
9426 };
9427 }
9428 }
9429 _ => {
9430 return SyncOutcome::Failed {
9431 error_code: "sync.invalid_request".to_owned(),
9432 message: "response does not start with RESP_HEADER".to_owned(),
9433 };
9434 }
9435 }
9436
9437 let mut failure: Option<(String, String)> = None;
9438 while let Some(frame) = frames.next() {
9439 match frame {
9440 frame @ Frame::PushResult { .. } => {
9441 let successful = matches!(
9442 &frame,
9443 Frame::PushResult {
9444 status: PushStatus::Applied | PushStatus::Cached,
9445 ..
9446 }
9447 );
9448 let mut results = vec![frame];
9449 if successful {
9450 while matches!(
9451 frames.peek(),
9452 Some(Frame::PushResult {
9453 status: PushStatus::Applied | PushStatus::Cached,
9454 ..
9455 })
9456 ) {
9457 if let Some(next) = frames.next() {
9458 results.push(next);
9459 }
9460 }
9461 }
9462 if self
9463 .handle_push_results(
9464 &results,
9465 &pushed_ids,
9466 &rejection_details_by_commit,
9467 &mut report,
9468 last_final_push_result_id.as_deref(),
9469 )
9470 .is_err()
9471 {
9472 failure = Some((
9473 "client.outcome_persistence_failed".to_owned(),
9474 "local commit outcome could not be persisted".to_owned(),
9475 ));
9476 break;
9477 }
9478 }
9479 Frame::PushResultDetails { .. } => {}
9480 Frame::SubStart {
9481 id,
9482 status,
9483 reason_code,
9484 effective_scopes,
9485 bootstrap: _,
9486 } => {
9487 let mut body = Vec::new();
9488 let mut sub_end: Option<(i64, Option<String>)> = None;
9489 for inner in frames.by_ref() {
9490 match inner {
9491 Frame::SubEnd {
9492 next_cursor,
9493 bootstrap_state,
9494 } => {
9495 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
9496 break;
9497 }
9498 Frame::Unknown { .. } => {}
9499 other => body.push(other),
9500 }
9501 }
9502 if let Err(SectionError::Abort(code, message)) = self.process_section(
9503 transport,
9504 &id,
9505 status,
9506 &reason_code,
9507 effective_scopes,
9508 body,
9509 sub_end,
9510 meta,
9511 &mut report,
9512 ) {
9513 failure = Some((code, message));
9514 break;
9515 }
9516 }
9517 Frame::Lease {
9518 lease_id,
9519 expires_at_ms,
9520 } => {
9521 self.set_lease_state(Some(LeaseState {
9524 lease_id: Some(lease_id),
9525 expires_at_ms: Some(expires_at_ms),
9526 error_code: None,
9527 }));
9528 }
9529 Frame::Error { code, message, .. } => {
9530 failure = Some((code, message));
9533 break;
9534 }
9535 Frame::Unknown { .. } => {}
9536 _ => {
9537 failure = Some((
9538 "sync.invalid_request".to_owned(),
9539 "unexpected frame in response".to_owned(),
9540 ));
9541 break;
9542 }
9543 }
9544 }
9545
9546 if self.overlay_dirty.get() {
9551 self.rebuild_overlay();
9552 }
9553 if let Some((error_code, message)) = failure {
9554 return SyncOutcome::Failed {
9555 error_code,
9556 message,
9557 };
9558 }
9559 if let Err(message) = self.drain_pending_evictions() {
9562 return SyncOutcome::Failed {
9563 error_code: "storage.failed".into(),
9564 message,
9565 };
9566 }
9567 self.ack_after_pull(transport);
9568 if self.upgrading && report.bootstrapping.is_empty() {
9571 self.set_upgrading(false);
9572 }
9573 SyncOutcome::Ok(report)
9574 }
9575
9576 fn handle_push_results(
9579 &mut self,
9580 frames: &[Frame],
9581 pushed_ids: &HashSet<&str>,
9582 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
9583 report: &mut SyncReport,
9584 last_final_push_result_id: Option<&str>,
9585 ) -> Result<(), String> {
9586 self.begin_observation("syncular_push_results")?;
9587 let conflict_count = self.conflicts.len();
9588 let rejection_count = self.rejections.len();
9589 let applied_count = report.applied.len();
9590 let rejected_count = report.rejected.len();
9591 let retryable_count = report.retryable.len();
9592 let was_dirty = self.overlay_dirty.get();
9593 let mut removed = Vec::new();
9594 let mut batch = ChangeAccumulator::default();
9595 let persisted = (|| {
9596 for frame in frames {
9597 let Frame::PushResult {
9598 client_commit_id,
9599 status,
9600 results,
9601 ..
9602 } = frame
9603 else {
9604 return Err("unexpected acknowledgement frame".to_owned());
9605 };
9606 if !pushed_ids.contains(client_commit_id.as_str()) {
9607 continue;
9608 }
9609 let Some(index) = self
9610 .outbox
9611 .iter()
9612 .position(|commit| commit.client_commit_id == *client_commit_id)
9613 else {
9614 continue;
9615 };
9616 let status = *status;
9617 let rejection_details = rejection_details_by_commit.get(client_commit_id);
9618 let operations = self.outbox[index].ops.clone();
9619 match status {
9620 PushStatus::Applied | PushStatus::Cached => {
9621 let journal_results = results
9624 .iter()
9625 .map(|result| {
9626 let op_index = match result {
9627 OpResult::Applied { op_index }
9628 | OpResult::Conflict { op_index, .. }
9629 | OpResult::Error { op_index, .. } => *op_index,
9630 };
9631 CommitOperationOutcome::Applied { op_index }
9632 })
9633 .collect::<Vec<_>>();
9634 let outcome_status = if status == PushStatus::Applied {
9635 CommitOutcomeStatus::Applied
9636 } else {
9637 CommitOutcomeStatus::Cached
9638 };
9639 let persisted = self
9640 .persist_commit_outcome(
9641 client_commit_id,
9642 outcome_status,
9643 &journal_results,
9644 None,
9645 )
9646 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9647 persisted?;
9648 self.overlay_dirty.set(true);
9649 batch.status = true;
9650 batch.outcomes = true;
9651 }
9652 PushStatus::Rejected => {
9653 if results.iter().any(|result| {
9654 matches!(
9655 result,
9656 OpResult::Error {
9657 code,
9658 retryable: true,
9659 ..
9660 } if code == "sync.idempotency_cache_miss"
9661 )
9662 }) {
9663 report.retryable.push(client_commit_id.to_owned());
9666 continue;
9667 }
9668
9669 let mut journal_results = Vec::with_capacity(results.len());
9670 let mut conflicts = Vec::new();
9671 let mut rejections = Vec::new();
9672 for result in results {
9673 match result {
9674 OpResult::Applied { op_index } => {
9675 journal_results.push(CommitOperationOutcome::Applied {
9676 op_index: *op_index,
9677 });
9678 }
9679 OpResult::Conflict {
9680 op_index,
9681 code,
9682 message,
9683 server_version,
9684 server_row,
9685 conflict_columns: conflict_columns_bitmap,
9686 } => {
9687 let operation = operations
9688 .get(*op_index as usize)
9689 .map(CommitOperation::from);
9690 let (table, row_id) = operation
9691 .as_ref()
9692 .map(|op| (op.table.clone(), op.row_id.clone()))
9693 .unwrap_or_default();
9694 let server_row_json = self
9695 .schema
9696 .table(&table)
9697 .and_then(|t| {
9698 decode_row_bytes(t, server_row, &self.encryption)
9699 .ok()
9700 .map(|row| (t, row))
9701 })
9702 .map(|(t, row)| {
9703 let mut map = Map::new();
9704 for (i, column) in t.columns.iter().enumerate() {
9705 map.insert(
9706 column.name.clone(),
9707 column_value_to_json(
9708 row.get(i).unwrap_or(&None),
9709 ),
9710 );
9711 }
9712 map
9713 })
9714 .unwrap_or_default();
9715 let conflict_columns = self
9718 .schema
9719 .table(&table)
9720 .map(|t| {
9721 t.columns
9722 .iter()
9723 .enumerate()
9724 .filter(|(index, _)| {
9725 conflict_columns_bitmap
9726 .get(index / 8)
9727 .is_some_and(|byte| {
9728 byte & (1 << (index % 8)) != 0
9729 })
9730 })
9731 .map(|(_, column)| column.name.clone())
9732 .collect::<Vec<_>>()
9733 })
9734 .unwrap_or_default();
9735 let conflict = ConflictRecord {
9736 client_commit_id: client_commit_id.to_owned(),
9737 op_index: *op_index,
9738 table,
9739 row_id,
9740 code: code.clone(),
9741 message: message.clone(),
9742 server_version: *server_version,
9743 server_row: server_row_json,
9744 conflict_columns,
9745 operation,
9746 };
9747 journal_results.push(CommitOperationOutcome::Conflict {
9748 conflict: conflict.clone(),
9749 });
9750 conflicts.push(conflict);
9751 }
9752 OpResult::Error {
9753 op_index,
9754 code,
9755 message,
9756 retryable,
9757 } => {
9758 let rejection = RejectionRecord {
9759 client_commit_id: client_commit_id.to_owned(),
9760 op_index: *op_index,
9761 code: code.clone(),
9762 message: message.clone(),
9763 retryable: *retryable,
9764 details: rejection_details
9765 .and_then(|details| details.get(op_index))
9766 .cloned(),
9767 operation: operations
9768 .get(*op_index as usize)
9769 .map(CommitOperation::from),
9770 };
9771 journal_results.push(CommitOperationOutcome::Error {
9772 rejection: rejection.clone(),
9773 });
9774 rejections.push(rejection);
9775 }
9776 }
9777 }
9778 let outcome_status = if conflicts.is_empty() {
9779 CommitOutcomeStatus::Rejected
9780 } else {
9781 CommitOutcomeStatus::Conflict
9782 };
9783 let persisted = self
9784 .persist_commit_outcome(
9785 client_commit_id,
9786 outcome_status,
9787 &journal_results,
9788 Some(&operations),
9789 )
9790 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9791 persisted?;
9792 batch.conflicts = !conflicts.is_empty();
9793 batch.rejections = !rejections.is_empty();
9794 batch.status = true;
9795 batch.outcomes = true;
9796 self.conflicts.extend(conflicts);
9797 self.rejections.extend(rejections);
9798 self.overlay_dirty.set(true);
9799 }
9800 }
9801 removed.push((index, self.outbox.remove(index)));
9802 if batch.status {
9803 for operation in &operations {
9804 if !self.record_row_scopes(
9805 &mut batch,
9806 &operation.table,
9807 &operation.row_id,
9808 false,
9809 ) {
9810 batch.table(&operation.table);
9811 }
9812 }
9813 }
9814 match status {
9815 PushStatus::Applied | PushStatus::Cached => {
9816 report.applied.push(client_commit_id.to_owned())
9817 }
9818 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
9819 }
9820 }
9821 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)) {
9822 self.prune_commit_outcomes()?;
9823 }
9824 self.finish_observation("syncular_push_results", batch)
9825 })();
9826 if let Err(error) = persisted {
9827 self.rollback_observation("syncular_push_results");
9828 for (index, commit) in removed.into_iter().rev() {
9829 self.outbox.insert(index, commit);
9830 }
9831 self.conflicts.truncate(conflict_count);
9832 self.rejections.truncate(rejection_count);
9833 self.overlay_dirty.set(was_dirty);
9834 report.applied.truncate(applied_count);
9835 report.rejected.truncate(rejected_count);
9836 report.retryable.truncate(retryable_count);
9837 return Err(error);
9838 }
9839 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
9840 Ok(())
9841 }
9842
9843 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
9844 status != PushStatus::Rejected
9845 || !results.iter().any(|result| {
9846 matches!(
9847 result,
9848 OpResult::Error {
9849 code,
9850 retryable: true,
9851 ..
9852 } if code == "sync.idempotency_cache_miss"
9853 )
9854 })
9855 }
9856
9857 #[allow(clippy::too_many_arguments)]
9860 fn process_section(
9861 &mut self,
9862 transport: &mut dyn Transport,
9863 id: &str,
9864 status: SubStatus,
9865 reason_code: &str,
9866 effective_scopes: Vec<(String, Vec<String>)>,
9867 body: Vec<Frame>,
9868 sub_end: Option<(i64, Option<String>)>,
9869 meta: &RequestMeta,
9870 report: &mut SyncReport,
9871 ) -> Result<(), SectionError> {
9872 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
9873 return Ok(()); };
9875 if status != SubStatus::Active && sub_end.is_none() {
9876 return Err(SectionError::Abort(
9877 "sync.invalid_request".into(),
9878 "subscription section without SUB_END".into(),
9879 ));
9880 }
9881 match status {
9882 SubStatus::Revoked => {
9883 self.begin_observation("syncular_revocation")
9884 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9885 let previous = self.subs[sub_index].clone();
9886 let was_dirty = self.overlay_dirty.get();
9887 let previous_outbox = self.outbox.clone();
9888 let rejection_count = self.rejections.len();
9889 let revoked_count = report.revoked.len();
9890 let failed_count = report.failed.len();
9891 let result =
9892 (|| {
9893 let mut batch = ChangeAccumulator::default();
9894 let registered = self.window_unit_by_sub_id(id);
9895 let (table, effective) = {
9897 let sub = &self.subs[sub_index];
9898 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9899 };
9900 let purged = self.purge_scope_rows(&table, &effective);
9901 match purged {
9902 Ok(()) => {
9903 self.record_scope_map(&mut batch, &table, &effective);
9904 let sub = &mut self.subs[sub_index];
9905 sub.state = SubState::Revoked;
9906 sub.reason_code = Some(if reason_code.is_empty() {
9907 "sync.scope_revoked".to_owned()
9908 } else {
9909 reason_code.to_owned()
9910 });
9911 report.revoked.push(id.to_owned());
9912 let doomed_effective = effective;
9913 let sub_table = table;
9914 self.persist_sub(&self.subs[sub_index].clone()).map_err(
9915 |message| SectionError::Abort("storage.failed".into(), message),
9916 )?;
9917 let dropped = self
9918 .drop_doomed_outbox(&sub_table, &doomed_effective)
9919 .map_err(|message| {
9920 SectionError::Abort("storage.failed".to_owned(), message)
9921 })?;
9922 if dropped {
9923 batch.status = true;
9924 batch.rejections = true;
9925 batch.outcomes = true;
9926 }
9927 self.delete_unreferenced_cached_blobs();
9930 }
9931 Err(()) => {
9932 let sub = &mut self.subs[sub_index];
9935 sub.state = SubState::Failed;
9936 sub.reason_code = Some("sync.scope_revoked".to_owned());
9937 report.failed.push(id.to_owned());
9938 self.persist_sub(&self.subs[sub_index].clone()).map_err(
9939 |message| SectionError::Abort("storage.failed".into(), message),
9940 )?;
9941 }
9942 }
9943 if let Some((base_key, unit)) = registered {
9944 batch.window(&base_key, &self.subs[sub_index].table, &unit);
9945 }
9946 self.rebuild_overlay_if_dirty();
9947 self.finish_observation("syncular_revocation", batch)
9948 .map_err(|message| {
9949 SectionError::Abort("storage.failed".to_owned(), message)
9950 })?;
9951 Ok(())
9952 })();
9953 if result.is_err() {
9954 self.rollback_observation("syncular_revocation");
9955 self.subs[sub_index] = previous;
9956 self.overlay_dirty.set(was_dirty);
9957 self.outbox = previous_outbox;
9958 self.rejections.truncate(rejection_count);
9959 report.revoked.truncate(revoked_count);
9960 report.failed.truncate(failed_count);
9961 }
9962 result
9963 }
9964 SubStatus::Reset => {
9965 self.begin_observation("syncular_reset")
9966 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9967 let previous = self.subs[sub_index].clone();
9968 let was_dirty = self.overlay_dirty.get();
9969 let reset_count = report.resets.len();
9970 let result = (|| {
9971 let mut batch = ChangeAccumulator::default();
9972 let registered = self.window_unit_by_sub_id(id);
9973 let sub = &mut self.subs[sub_index];
9976 sub.cursor = -1;
9977 sub.bootstrap_state = None;
9978 report.resets.push(id.to_owned());
9979 self.persist_sub(&self.subs[sub_index].clone())
9980 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9981 if let Some((base_key, unit)) = registered {
9982 batch.window(&base_key, &self.subs[sub_index].table, &unit);
9983 }
9984 self.finish_observation("syncular_reset", batch)
9985 .map_err(|message| {
9986 SectionError::Abort("storage.failed".to_owned(), message)
9987 })?;
9988 Ok(())
9989 })();
9990 if result.is_err() {
9991 self.rollback_observation("syncular_reset");
9992 self.subs[sub_index] = previous;
9993 self.overlay_dirty.set(was_dirty);
9994 report.resets.truncate(reset_count);
9995 }
9996 result
9997 }
9998 SubStatus::Active => {
9999 let fresh = meta
10000 .fresh
10001 .iter()
10002 .find(|(fid, _)| fid == id)
10003 .map(|(_, fresh)| *fresh)
10004 .unwrap_or(false);
10005 let previous = self.subs[sub_index].clone();
10006 self.subs[sub_index].effective = Some(effective_scopes);
10007 let outcome = self
10008 .apply_section_body(transport, sub_index, body, fresh, meta, report)
10009 .and_then(|()| {
10010 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
10011 SectionError::Abort(
10012 "sync.invalid_request".into(),
10013 "subscription section without SUB_END".into(),
10014 )
10015 })?;
10016 self.apply_sub_end(sub_index, cursor, bootstrap_state)
10017 .map_err(|message| {
10018 SectionError::Abort("storage.failed".into(), message)
10019 })
10020 });
10021 match outcome {
10022 Ok(()) => {
10023 if self.subs[sub_index].bootstrap_state.is_some() {
10024 report.bootstrapping.push(id.to_owned());
10025 }
10026 Ok(())
10027 }
10028 Err(SectionError::FailClosed) => {
10029 self.subs[sub_index] = previous.clone();
10030 self.begin_observation("syncular_section_failure")
10031 .map_err(|message| {
10032 SectionError::Abort("storage.failed".into(), message)
10033 })?;
10034 let persisted = (|| {
10035 let mut batch = ChangeAccumulator::default();
10036 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
10037 batch.window(&base_key, &self.subs[sub_index].table, &unit);
10038 }
10039 self.subs[sub_index].state = SubState::Failed;
10040 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
10041 self.persist_sub(&self.subs[sub_index])?;
10042 self.finish_observation("syncular_section_failure", batch)
10043 })();
10044 if let Err(message) = persisted {
10045 self.rollback_observation("syncular_section_failure");
10046 self.subs[sub_index] = previous;
10047 return Err(SectionError::Abort("storage.failed".into(), message));
10048 }
10049 report.failed.push(id.to_owned());
10050 Ok(())
10051 }
10052 Err(error) => {
10053 self.subs[sub_index] = previous;
10056 Err(error)
10057 }
10058 }
10059 }
10060 }
10061 }
10062
10063 fn apply_sub_end(
10064 &mut self,
10065 sub_index: usize,
10066 cursor: i64,
10067 bootstrap_state: Option<String>,
10068 ) -> Result<(), String> {
10069 #[cfg(feature = "bench-internals")]
10070 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
10071 let previous = self.subs[sub_index].clone();
10072 self.begin_observation("syncular_sub_end")?;
10073 let persisted = (|| {
10074 let mut batch = ChangeAccumulator::default();
10075 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
10076 && cursor >= 0
10077 && bootstrap_state.is_none();
10078 if completed {
10079 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
10080 batch.window(&base_key, &previous.table, &unit);
10081 }
10082 }
10083 let sub = &mut self.subs[sub_index];
10084 sub.cursor = cursor;
10085 sub.bootstrap_state = bootstrap_state;
10086 sub.synced_once = true;
10087 self.persist_sub(&self.subs[sub_index])?;
10088 self.finish_observation("syncular_sub_end", batch)
10089 })();
10090 if persisted.is_err() {
10091 self.rollback_observation("syncular_sub_end");
10092 self.subs[sub_index] = previous;
10093 }
10094 persisted
10095 }
10096
10097 fn apply_section_body(
10098 &mut self,
10099 transport: &mut dyn Transport,
10100 sub_index: usize,
10101 body: Vec<Frame>,
10102 fresh: bool,
10103 meta: &RequestMeta,
10104 report: &mut SyncReport,
10105 ) -> Result<(), SectionError> {
10106 let mut saw_segment = false;
10107 for frame in body {
10108 match frame {
10109 Frame::Commit {
10110 tables, changes, ..
10111 } => {
10112 self.apply_commit_frame(&tables, &changes)
10113 .map_err(|(c, m)| SectionError::Abort(c, m))?;
10114 report.commits_applied += 1;
10115 }
10116 Frame::SegmentInline { payload } => {
10117 let segment = decode_rows_segment(&payload)
10118 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
10119 self.progress.update(|p| {
10120 p.phase = ProgressPhase::Import;
10121 p.subscription_id = Some(self.subs[sub_index].id.clone());
10122 p.table = Some(self.subs[sub_index].table.clone());
10123 p.segment_id = None;
10124 p.bytes_received = payload.len() as u64;
10125 p.bytes_total = Some(payload.len() as u64);
10126 p.rows_processed = 0;
10127 p.rows_total =
10128 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
10129 });
10130 let first = !saw_segment;
10131 saw_segment = true;
10132 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10133 report.segment_rows_applied += applied;
10134 }
10135 Frame::SegmentRef {
10136 segment_id,
10137 byte_length,
10138 media_type,
10139 table,
10140 row_count,
10141 as_of_commit_seq,
10142 scope_digest,
10143 row_cursor,
10144 next_row_cursor,
10145 url,
10146 url_expires_at_ms,
10147 ..
10148 } => {
10149 let advertised = match media_type {
10152 MediaType::Rows => {
10153 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
10154 || meta.accept & ACCEPT_INLINE_ROWS != 0
10155 }
10156 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
10157 };
10158 if !advertised {
10159 return Err(SectionError::Abort(
10160 "sync.invalid_request".to_owned(),
10161 format!(
10162 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
10163 media_type.name()
10164 ),
10165 ));
10166 }
10167 self.progress.update(|p| {
10168 p.phase = ProgressPhase::Download;
10169 p.subscription_id = Some(self.subs[sub_index].id.clone());
10170 p.table = Some(table.clone());
10171 p.segment_id = Some(segment_id.clone());
10172 p.bytes_received = 0;
10173 p.bytes_total = u64::try_from(byte_length).ok();
10174 p.rows_processed = 0;
10175 p.rows_total = u64::try_from(row_count).ok();
10176 });
10177 let observer = self.progress.clone();
10178 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
10179 let bytes = if let Some(url) = url {
10180 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
10185 return Err(SectionError::Abort(
10186 "sync.invalid_request".to_owned(),
10187 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
10188 .to_owned(),
10189 ));
10190 }
10191 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
10193 return Err(SectionError::Abort(
10194 "sync.segment_expired".to_owned(),
10195 format!(
10196 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
10197 ),
10198 ));
10199 }
10200 transport
10201 .fetch_url(&url, &mut on_progress)
10202 .map_err(|e| SectionError::Abort(e.code, e.message))?
10203 } else {
10204 let requested_scopes_json =
10205 canonical_scope_json(&self.subs[sub_index].requested);
10206 transport
10207 .download_segment(
10208 &SegmentRequest {
10209 segment_id: segment_id.clone(),
10210 table,
10211 requested_scopes_json,
10212 },
10213 &mut on_progress,
10214 )
10215 .map_err(|e| SectionError::Abort(e.code, e.message))?
10216 };
10217 on_progress(bytes.len() as u64);
10219 let digest = Sha256::digest(&bytes);
10220 let expected = segment_id
10221 .strip_prefix("sha256:")
10222 .unwrap_or(segment_id.as_str());
10223 if bytes_to_hex(&digest) != expected {
10224 self.schedule_background_retry();
10226 return Err(SectionError::Abort(
10227 "sync.invalid_request".to_owned(),
10228 "segment bytes do not match the content address (§5.1)".to_owned(),
10229 ));
10230 }
10231 self.progress.update(|p| p.phase = ProgressPhase::Import);
10232 if media_type == MediaType::Sqlite {
10233 if row_cursor.is_some() || next_row_cursor.is_some() {
10236 return Err(SectionError::Abort(
10237 "sync.invalid_request".to_owned(),
10238 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
10239 .to_owned(),
10240 ));
10241 }
10242 let first = !saw_segment;
10243 saw_segment = true;
10244 let applied = self.apply_sqlite_segment(
10245 sub_index,
10246 &bytes,
10247 fresh && first,
10248 row_count,
10249 as_of_commit_seq,
10250 &scope_digest,
10251 )?;
10252 report.segment_rows_applied += applied;
10253 } else {
10254 let segment = decode_rows_segment(&bytes).map_err(|e| {
10255 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
10256 })?;
10257 let first = row_cursor.is_none();
10258 saw_segment = true;
10259 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10260 report.segment_rows_applied += applied;
10261 }
10262 }
10263 Frame::Unknown { .. } => {}
10264 Frame::Error { code, message, .. } => {
10265 return Err(SectionError::Abort(code, message))
10266 }
10267 _ => {
10268 return Err(SectionError::Abort(
10269 "sync.invalid_request".to_owned(),
10270 "unexpected frame inside a subscription section".to_owned(),
10271 ));
10272 }
10273 }
10274 }
10275 Ok(())
10276 }
10277
10278 fn apply_commit_frame(
10280 &mut self,
10281 tables: &[String],
10282 changes: &[ssp2::model::Change],
10283 ) -> Result<(), (String, String)> {
10284 #[cfg(feature = "bench-internals")]
10285 let _phase = self.benchmark_phases.start(Phase::CommitApply);
10286 let was_dirty = self.overlay_dirty.get();
10287 self.begin_observation("syncular_commit")
10288 .map_err(|message| ("storage.failed".into(), message))?;
10289 let applied = (|| {
10290 let mut batch = ChangeAccumulator::default();
10291 self.record_commit_changes(&mut batch, tables, changes);
10292 self.apply_commit_changes(tables, changes)?;
10293 self.rebuild_overlay_if_dirty();
10294 self.finish_observation("syncular_commit", batch)
10295 .map_err(|message| ("storage.failed".into(), message))
10296 })();
10297 if applied.is_err() {
10298 self.rollback_observation("syncular_commit");
10299 self.overlay_dirty.set(was_dirty);
10300 }
10301 applied
10302 }
10303
10304 fn apply_commit_changes(
10305 &mut self,
10306 tables: &[String],
10307 changes: &[ssp2::model::Change],
10308 ) -> Result<(), (String, String)> {
10309 let mirror_visible = !self.overlay_dirty.get()
10314 && (self.outbox.is_empty()
10315 || changes.iter().all(|change| {
10316 tables
10317 .get(change.table_index as usize)
10318 .and_then(|name| self.schema.table(name))
10319 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
10320 }));
10321 for change in changes {
10322 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
10323 (
10324 "sync.invalid_request".to_owned(),
10325 "change tableIndex out of range".to_owned(),
10326 )
10327 })?;
10328 let table = self.schema.table(table_name).ok_or_else(|| {
10329 (
10330 "sync.schema_mismatch".to_owned(),
10331 format!("change targets unknown table {table_name:?}"),
10332 )
10333 })?;
10334 match change.op {
10335 Op::Upsert => {
10336 let payload = change.row.as_ref().ok_or_else(|| {
10337 (
10338 "sync.invalid_request".to_owned(),
10339 "upsert change without row payload".to_owned(),
10340 )
10341 })?;
10342 let row = {
10344 #[cfg(feature = "bench-internals")]
10345 let _phase = self.benchmark_phases.start(Phase::RowDecode);
10346 decode_row_bytes(table, payload, &self.encryption)
10347 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
10348 };
10349 let version = change.row_version.unwrap_or(0);
10350 let table_name = table.name.clone();
10351 self.write_base_row(&table_name, &row, version)
10352 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10353 if mirror_visible {
10354 self.write_row(&visible_table(&table_name), &table_name, &row, version)
10355 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10356 }
10357 }
10358 Op::Delete => {
10359 self.delete_base_row(table_name, &change.row_id)
10360 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10361 if mirror_visible {
10362 let sql = format!(
10363 "DELETE FROM {} WHERE {}",
10364 visible_table(table_name),
10365 row_id_predicate(table)
10366 );
10367 self.conn
10368 .prepare_cached(&sql)
10369 .and_then(|mut statement| {
10370 statement.execute(rusqlite::params![change.row_id])
10371 })
10372 .map_err(|error| {
10373 ("sync.invalid_request".to_owned(), error.to_string())
10374 })?;
10375 }
10376 }
10377 }
10378 }
10379 if mirror_visible {
10380 if !self.outbox.is_empty() {
10381 let changed_rows: HashSet<(&str, &str)> = changes
10382 .iter()
10383 .map(|change| {
10384 (
10385 tables[change.table_index as usize].as_str(),
10386 change.row_id.as_str(),
10387 )
10388 })
10389 .collect();
10390 self.apply_outbox_ops(
10391 self.outbox
10392 .iter()
10393 .flat_map(|commit| &commit.ops)
10394 .filter(|op| {
10395 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
10396 }),
10397 );
10398 }
10399 self.overlay_dirty.set(false);
10400 }
10401 Ok(())
10402 }
10403
10404 fn apply_segment(
10409 &mut self,
10410 sub_index: usize,
10411 segment: &RowsSegment,
10412 first_fresh_page: bool,
10413 ) -> Result<u32, SectionError> {
10414 let (sub_table, effective) = {
10415 let sub = &self.subs[sub_index];
10416 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10417 };
10418 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10419 SectionError::Abort(
10420 "sync.schema_mismatch".to_owned(),
10421 format!("subscription table {sub_table:?} is not in the client schema"),
10422 )
10423 })?;
10424 let matches = segment.table == table.name
10429 && segment.schema_version == self.schema.version
10430 && segment.columns.len() == table.wire_columns.len()
10431 && segment
10432 .columns
10433 .iter()
10434 .zip(table.wire_columns.iter())
10435 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
10436 if !matches {
10437 return Err(SectionError::Abort(
10438 "sync.schema_mismatch".to_owned(),
10439 "segment column table does not match the generated schema (§5.2)".to_owned(),
10440 ));
10441 }
10442 let mut applied = 0u32;
10443 let empty = Vec::new();
10444 let blocks = if segment.blocks.is_empty() {
10445 std::slice::from_ref(&empty)
10446 } else {
10447 &segment.blocks
10448 };
10449 for (index, block) in blocks.iter().enumerate() {
10450 let clear = first_fresh_page && index == 0;
10451 if !clear && block.is_empty() {
10452 continue;
10453 }
10454 let was_dirty = self.overlay_dirty.get();
10455 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10456 && self
10457 .outbox
10458 .iter()
10459 .flat_map(|commit| &commit.ops)
10460 .any(|op| op.table == table.name);
10461 let mirror_visible = !reconcile_pending;
10462 self.begin_observation("syncular_segment_block")
10463 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10464 let outcome = (|| {
10465 self.rebuild_overlay_if_dirty();
10466 let mut changed_keys = Vec::new();
10467 let mut batch = ChangeAccumulator::default();
10468 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
10469 batch.table(&table.name);
10470 }
10471 if clear {
10472 self.purge_scope_rows(&table.name, &effective)
10473 .map_err(|()| SectionError::FailClosed)?;
10474 }
10475 for (row_index, row) in block.iter().enumerate() {
10476 let decrypted;
10477 let values = if table.has_encrypted_columns() {
10478 let mut values = row.values.clone();
10479 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
10480 .map_err(|message| {
10481 SectionError::Abort("client.decrypt_failed".into(), message)
10482 })?;
10483 decrypted = values;
10484 &decrypted
10485 } else {
10486 &row.values
10487 };
10488 self.write_base_row(&table.name, values, row.server_version)
10489 .map_err(|message| {
10490 SectionError::Abort("sync.invalid_request".into(), message)
10491 })?;
10492 if reconcile_pending {
10493 changed_keys.push(
10494 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
10495 |message| SectionError::Abort("storage.failed".into(), message),
10496 )?,
10497 );
10498 }
10499 if mirror_visible {
10500 self.write_row(
10501 &visible_table(&table.name),
10502 &table.name,
10503 values,
10504 row.server_version,
10505 )
10506 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10507 }
10508 let processed = u64::from(applied) + row_index as u64 + 1;
10509 if processed.is_multiple_of(1024) {
10510 self.progress.update(|p| p.rows_processed = processed);
10511 }
10512 }
10513 if reconcile_pending {
10514 self.reconcile_imported_rows(&table, &mut changed_keys)
10515 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10516 }
10517 self.apply_outbox_ops(
10518 self.outbox
10519 .iter()
10520 .flat_map(|commit| &commit.ops)
10521 .filter(|op| op.table == table.name),
10522 );
10523 self.overlay_dirty.set(false);
10524 self.finish_observation("syncular_segment_block", batch)
10525 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
10526 })();
10527 if let Err(error) = outcome {
10528 self.rollback_observation("syncular_segment_block");
10529 self.overlay_dirty.set(was_dirty);
10530 return Err(error);
10531 }
10532 applied += block.len() as u32;
10533 self.progress
10534 .update(|p| p.rows_processed = u64::from(applied));
10535 std::thread::yield_now();
10536 }
10537 self.progress
10538 .update(|p| p.rows_processed = u64::from(applied));
10539 Ok(applied)
10540 }
10541
10542 fn apply_sqlite_segment(
10550 &mut self,
10551 sub_index: usize,
10552 bytes: &[u8],
10553 first_fresh_page: bool,
10554 row_count: i64,
10555 as_of_commit_seq: i64,
10556 scope_digest: &str,
10557 ) -> Result<u32, SectionError> {
10558 let invalid = |detail: &str| {
10559 SectionError::Abort(
10560 "sync.invalid_request".to_owned(),
10561 format!("sqlite segment rejected: {detail} (§5.3)"),
10562 )
10563 };
10564 let (sub_table, effective) = {
10565 let sub = &self.subs[sub_index];
10566 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10567 };
10568 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10569 SectionError::Abort(
10570 "sync.schema_mismatch".to_owned(),
10571 format!("subscription table {sub_table:?} is not in the client schema"),
10572 )
10573 })?;
10574
10575 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
10576 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
10577 let img = match rusqlite::Connection::open_with_flags(
10578 &path,
10579 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
10580 ) {
10581 Ok(conn) => conn,
10582 Err(_) => {
10583 let _ = std::fs::remove_file(&path);
10584 return Err(invalid("bytes do not open as a SQLite database"));
10585 }
10586 };
10587 let outcome = self.apply_sqlite_image(
10588 &img,
10589 &table,
10590 first_fresh_page,
10591 &effective,
10592 row_count,
10593 as_of_commit_seq,
10594 scope_digest,
10595 );
10596 drop(img);
10597 let _ = std::fs::remove_file(&path);
10598 outcome
10599 }
10600
10601 #[allow(clippy::too_many_arguments)]
10602 fn apply_sqlite_image(
10603 &mut self,
10604 img: &rusqlite::Connection,
10605 table: &crate::schema::TableSchema,
10606 first_fresh_page: bool,
10607 effective: &[(String, Vec<String>)],
10608 row_count: i64,
10609 as_of_commit_seq: i64,
10610 scope_digest: &str,
10611 ) -> Result<u32, SectionError> {
10612 let invalid = |detail: String| {
10613 SectionError::Abort(
10614 "sync.invalid_request".to_owned(),
10615 format!("sqlite segment rejected: {detail} (§5.3)"),
10616 )
10617 };
10618
10619 type MetaRow = (i64, String, i64, i64, String, i64, i64);
10622 let meta: MetaRow = img
10623 .query_row(
10624 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
10625 \"scopeDigest\", \"rowCount\",
10626 (SELECT count(*) FROM _syncular_segment)
10627 FROM _syncular_segment",
10628 [],
10629 |row| {
10630 Ok((
10631 row.get(0)?,
10632 row.get(1)?,
10633 row.get(2)?,
10634 row.get(3)?,
10635 row.get(4)?,
10636 row.get(5)?,
10637 row.get(6)?,
10638 ))
10639 },
10640 )
10641 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
10642 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
10643 if meta_count != 1 {
10644 return Err(invalid(format!(
10645 "_syncular_segment must contain exactly one row, found {meta_count}"
10646 )));
10647 }
10648 if format != 1 {
10649 return Err(invalid(format!("format {format}")));
10650 }
10651 if meta_table != table.name {
10652 return Err(invalid(format!("image table {meta_table:?}")));
10653 }
10654 if schema_version != i64::from(self.schema.version) {
10655 return Err(invalid(format!("schemaVersion {schema_version}")));
10656 }
10657 if pin != as_of_commit_seq {
10658 return Err(invalid(format!("asOfCommitSeq {pin}")));
10659 }
10660 if digest != scope_digest {
10661 return Err(invalid("scopeDigest mismatch".to_owned()));
10662 }
10663 if meta_rows != row_count {
10664 return Err(invalid(format!("rowCount {meta_rows}")));
10665 }
10666
10667 let mut names: Vec<String> = Vec::new();
10669 let mut primary_keys: Vec<String> = Vec::new();
10670 {
10671 let mut stmt = img
10672 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
10673 .map_err(|_| invalid("image data table missing".to_owned()))?;
10674 let mut rows = stmt
10675 .query([])
10676 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
10677 while let Some(row) = rows
10678 .next()
10679 .map_err(|_| invalid("image data table unreadable".to_owned()))?
10680 {
10681 if row.get::<_, i64>(5).map_err(|e| invalid(e.to_string()))? > 0 {
10682 primary_keys.push(row.get(1).map_err(|e| invalid(e.to_string()))?);
10683 }
10684 names.push(
10685 row.get::<_, String>(1)
10686 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
10687 );
10688 }
10689 }
10690 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
10691 expected.push("_syncular_version");
10692 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
10693 return Err(SectionError::Abort(
10694 "sync.schema_mismatch".to_owned(),
10695 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
10696 ));
10697 }
10698
10699 if primary_keys != vec![table.primary_key.clone()] {
10700 return Err(invalid(
10701 "image primary key does not match generated schema".into(),
10702 ));
10703 }
10704 let actual_count: i64 = img
10705 .query_row(
10706 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
10707 [],
10708 |row| row.get(0),
10709 )
10710 .map_err(|e| invalid(e.to_string()))?;
10711 if actual_count != row_count {
10712 return Err(invalid("image row count does not match descriptor".into()));
10713 }
10714 let insert = self.insert_row_sql(&base_table(&table.name), table);
10715 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
10716 let column_list = names
10717 .iter()
10718 .map(|n| quote_ident(n))
10719 .collect::<Vec<_>>()
10720 .join(", ");
10721 let mut stmt = img
10722 .prepare(&format!(
10723 "SELECT {column_list} FROM {} ORDER BY {}",
10724 quote_ident(&table.name),
10725 quote_ident(&table.primary_key)
10726 ))
10727 .map_err(|e| invalid(e.to_string()))?;
10728 let mut rows = stmt.query([]).map_err(|e| invalid(e.to_string()))?;
10729 let mut applied = 0u32;
10730 loop {
10731 let clear = applied == 0 && first_fresh_page;
10732 let was_dirty = self.overlay_dirty.get();
10733 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10734 && self
10735 .outbox
10736 .iter()
10737 .flat_map(|commit| &commit.ops)
10738 .any(|op| op.table == table.name);
10739 let mirror_visible = !reconcile_pending;
10740 self.begin_observation("syncular_image_chunk")
10741 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10742 let result = (|| {
10743 self.rebuild_overlay_if_dirty();
10744 let mut changed_keys = Vec::new();
10745 let mut batch = ChangeAccumulator::default();
10746 if clear {
10747 if self.scoped_rows_exist(&table.name, effective) {
10748 batch.table(&table.name);
10749 }
10750 self.purge_scope_rows(&table.name, effective)
10751 .map_err(|()| SectionError::FailClosed)?;
10752 }
10753 let mut processed = 0u32;
10754 {
10755 let mut ins = self
10756 .conn
10757 .prepare_cached(&insert)
10758 .map_err(|e| invalid(e.to_string()))?;
10759 let mut mirror = if mirror_visible {
10760 Some(
10761 self.conn
10762 .prepare_cached(&visible_insert)
10763 .map_err(|e| invalid(e.to_string()))?,
10764 )
10765 } else {
10766 None
10767 };
10768 for _ in 0..1024 {
10769 let Some(row) = rows.next().map_err(|e| invalid(e.to_string()))? else {
10770 break;
10771 };
10772 for (i, column) in table.columns.iter().enumerate() {
10773 let cell = row.get_ref(i).map_err(|e| invalid(e.to_string()))?;
10774 let param = image_cell_param(column, cell).map_err(invalid)?;
10775 ins.raw_bind_parameter(i + 1, ¶m)
10776 .map_err(|e| invalid(e.to_string()))?;
10777 if let Some(mirror) = &mut mirror {
10778 mirror
10779 .raw_bind_parameter(i + 1, ¶m)
10780 .map_err(|e| invalid(e.to_string()))?;
10781 }
10782 }
10783 let version_index = table.columns.len();
10784 let version: i64 =
10785 row.get(version_index).map_err(|e| invalid(e.to_string()))?;
10786 if version < 1 {
10787 return Err(invalid("image row version must be positive".into()));
10788 }
10789 ins.raw_bind_parameter(version_index + 1, version)
10790 .map_err(|e| invalid(e.to_string()))?;
10791 ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
10792 if let Some(mirror) = &mut mirror {
10793 mirror
10794 .raw_bind_parameter(version_index + 1, version)
10795 .map_err(|e| invalid(e.to_string()))?;
10796 mirror.raw_execute().map_err(|e| invalid(e.to_string()))?;
10797 }
10798 if reconcile_pending {
10799 changed_keys.push(
10800 row.get::<_, SqlValue>(table.pk_index)
10801 .map_err(|error| invalid(error.to_string()))?,
10802 );
10803 }
10804 processed += 1;
10805 }
10806 }
10807 if processed > 0 {
10808 batch.table(&table.name);
10809 self.overlay_dirty.set(true);
10810 }
10811 if reconcile_pending {
10812 self.reconcile_imported_rows(table, &mut changed_keys)
10813 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10814 }
10815 self.apply_outbox_ops(
10816 self.outbox
10817 .iter()
10818 .flat_map(|commit| &commit.ops)
10819 .filter(|op| op.table == table.name),
10820 );
10821 self.overlay_dirty.set(false);
10822 self.finish_observation("syncular_image_chunk", batch)
10823 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10824 Ok(processed)
10825 })();
10826 let processed = match result {
10827 Ok(processed) => processed,
10828 Err(error) => {
10829 self.rollback_observation("syncular_image_chunk");
10830 self.overlay_dirty.set(was_dirty);
10831 return Err(error);
10832 }
10833 };
10834 applied += processed;
10835 self.progress
10836 .update(|p| p.rows_processed = u64::from(applied));
10837 std::thread::yield_now();
10838 if i64::from(applied) == actual_count {
10839 break;
10840 }
10841 }
10842 Ok(applied)
10843 }
10844
10845 fn reconcile_imported_rows(
10849 &self,
10850 table: &TableSchema,
10851 keys: &mut Vec<SqlValue>,
10852 ) -> Result<(), String> {
10853 let visible = visible_table(&table.name);
10854 let base = base_table(&table.name);
10855 let pk = quote_ident(&table.primary_key);
10856 let predicate = row_id_predicate(table);
10857 let mut lookup = self.conn.prepare_cached(&format!(
10858 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
10859 )).map_err(|error| error.to_string())?;
10860 let mut deleted = HashSet::new();
10861 for op in self
10862 .outbox
10863 .iter()
10864 .flat_map(|commit| &commit.ops)
10865 .filter(|op| op.table == table.name)
10866 {
10867 if op.upsert {
10868 if let Some(values) = &op.values {
10869 let key = json_to_column_value(
10870 &table.columns[table.pk_index],
10871 values.get(&table.primary_key),
10872 )?;
10873 keys.push(owned_sql_value(RowParam::Cell(&key))?);
10874 }
10875 } else if deleted.insert(&op.row_id) {
10876 keys.extend(
10877 lookup
10878 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
10879 .map_err(|error| error.to_string())?
10880 .collect::<Result<Vec<_>, _>>()
10881 .map_err(|error| error.to_string())?,
10882 );
10883 }
10884 }
10885 for chunk in keys.chunks(400) {
10888 let holes = vec!["?"; chunk.len()].join(",");
10889 self.conn
10890 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
10891 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10892 .map_err(|error| error.to_string())?;
10893 }
10894 let updates = table
10895 .columns
10896 .iter()
10897 .map(|column| column.name.as_str())
10898 .chain(std::iter::once("_syncular_version"))
10899 .filter(|name| *name != table.primary_key)
10900 .map(|name| {
10901 let name = quote_ident(name);
10902 format!("{name}=excluded.{name}")
10903 })
10904 .collect::<Vec<_>>()
10905 .join(",");
10906 for chunk in keys.chunks(400) {
10907 let holes = vec!["?"; chunk.len()].join(",");
10908 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
10909 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10910 .map_err(|error| error.to_string())?;
10911 }
10912 Ok(())
10913 }
10914
10915 fn compile_local_data_purge(
10918 &self,
10919 input: &LocalDataPurgeInput,
10920 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
10921 let invalid = |message: String| format!("sync.invalid_request: {message}");
10922 if input.purge_id.is_empty()
10923 || input.purge_id.len() > 128
10924 || !is_local_operation_code_like(&input.purge_id)
10925 {
10926 return Err(invalid(
10927 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
10928 ));
10929 }
10930 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
10931 return Err(invalid(format!(
10932 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
10933 )));
10934 }
10935
10936 let mut deduplicated: BTreeMap<
10937 String,
10938 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
10939 > = BTreeMap::new();
10940 for target in &input.targets {
10941 let table = self.schema.table(&target.table).ok_or_else(|| {
10942 invalid(format!(
10943 "local purge names unknown table {:?}",
10944 target.table
10945 ))
10946 })?;
10947 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
10948 return Err(invalid(format!(
10949 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
10950 target.table
10951 )));
10952 }
10953 let mut selectors = Vec::with_capacity(target.selectors.len());
10954 let mut canonical_selectors = BTreeMap::new();
10955 for (column_name, raw_values) in &target.selectors {
10956 let Some((column_index, column)) = table
10957 .columns
10958 .iter()
10959 .enumerate()
10960 .find(|(_, column)| column.name == *column_name)
10961 else {
10962 return Err(invalid(format!(
10963 "local purge target {:?} names unknown column {:?}",
10964 target.table, column_name
10965 )));
10966 };
10967 let encrypted = table
10968 .encrypted_columns
10969 .iter()
10970 .any(|candidate| candidate.index == column_index);
10971 if column.ty != ColumnType::String || encrypted {
10972 return Err(invalid(format!(
10973 "local purge selector {:?}.{:?} must be a plaintext string column",
10974 target.table, column_name
10975 )));
10976 }
10977 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
10978 return Err(invalid(format!(
10979 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
10980 target.table, column_name
10981 )));
10982 }
10983 let mut values = raw_values.clone();
10984 values.sort();
10985 values.dedup();
10986 if values.iter().any(|value| {
10987 value.is_empty()
10988 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
10989 || !is_local_operation_code_like(value)
10990 }) {
10991 return Err(invalid(format!(
10992 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
10993 )));
10994 }
10995 selectors.push((column_name.clone(), values.clone()));
10996 canonical_selectors.insert(column_name.clone(), values);
10997 }
10998 let canonical = LocalDataPurgeTarget {
10999 table: target.table.clone(),
11000 selectors: canonical_selectors,
11001 };
11002 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
11003 deduplicated.insert(
11004 key,
11005 (
11006 CompiledLocalDataPurgeTarget {
11007 table: target.table.clone(),
11008 selectors,
11009 },
11010 canonical,
11011 ),
11012 );
11013 }
11014 let targets = deduplicated
11015 .values()
11016 .map(|(compiled, _)| compiled.clone())
11017 .collect::<Vec<_>>();
11018 let canonical = deduplicated
11019 .values()
11020 .map(|(_, target)| target.clone())
11021 .collect::<Vec<_>>();
11022 let canonical_plan =
11023 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
11024 Ok((targets, canonical_plan))
11025 }
11026
11027 fn local_purge_base_row_ids(
11028 &self,
11029 targets: &[CompiledLocalDataPurgeTarget],
11030 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
11031 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11032 for target in targets {
11033 let table = self
11034 .schema
11035 .table(&target.table)
11036 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
11037 let mut clauses = Vec::with_capacity(target.selectors.len());
11038 let mut params = Vec::new();
11039 for (column, values) in &target.selectors {
11040 clauses.push(format!(
11041 "{} IN ({})",
11042 quote_ident(column),
11043 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
11044 ));
11045 params.extend(values.iter().cloned().map(SqlValue::Text));
11046 }
11047 let sql = format!(
11048 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
11049 quote_ident(&table.primary_key),
11050 base_table(&target.table),
11051 clauses.join(" AND ")
11052 );
11053 let mut statement = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
11054 let rows = statement
11055 .query_map(rusqlite::params_from_iter(params), |row| {
11056 row.get::<_, String>(0)
11057 })
11058 .map_err(|error| error.to_string())?;
11059 let ids = by_table.entry(target.table.clone()).or_default();
11060 for row in rows {
11061 ids.insert(row.map_err(|error| error.to_string())?);
11062 }
11063 }
11064 Ok(by_table)
11065 }
11066
11067 fn local_purge_values_match(
11068 target: &CompiledLocalDataPurgeTarget,
11069 values: &Map<String, Value>,
11070 ) -> bool {
11071 target.selectors.iter().all(|(column, allowed)| {
11072 values
11073 .get(column)
11074 .and_then(Value::as_str)
11075 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
11076 })
11077 }
11078
11079 pub fn purge_local_data(
11083 &mut self,
11084 input: &LocalDataPurgeInput,
11085 ) -> Result<LocalDataPurgeResult, String> {
11086 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
11087 let meta_key = format!("localPurge:{}", input.purge_id);
11088 let applied_plan = self
11089 .conn
11090 .query_row(
11091 "SELECT value FROM _syncular_meta WHERE key = ?1",
11092 rusqlite::params![meta_key],
11093 |row| row.get::<_, String>(0),
11094 )
11095 .optional()
11096 .map_err(|error| error.to_string())?;
11097 if let Some(applied_plan) = applied_plan {
11098 if applied_plan != canonical_plan {
11099 return Err(format!(
11100 "sync.invalid_request: local purge id {:?} was already used with a different plan",
11101 input.purge_id
11102 ));
11103 }
11104 return Ok(LocalDataPurgeResult {
11105 already_applied: true,
11106 purged_rows: 0,
11107 dropped_commits: 0,
11108 });
11109 }
11110
11111 let _ = self.drop_previous_version();
11115
11116 let prior_outbox = self.outbox.clone();
11117 let prior_rejection_count = self.rejections.len();
11118 let prior_overlay_dirty = self.overlay_dirty.get();
11119 self.begin_observation("syncular_local_purge")?;
11120 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
11121 let row_ids = self.local_purge_base_row_ids(&targets)?;
11122 let doomed = self
11123 .outbox
11124 .iter()
11125 .filter(|commit| {
11126 commit.ops.iter().any(|operation| {
11127 let matching_targets = targets
11128 .iter()
11129 .filter(|target| target.table == operation.table)
11130 .collect::<Vec<_>>();
11131 if matching_targets.is_empty() {
11132 return false;
11133 }
11134 if row_ids
11135 .get(&operation.table)
11136 .is_some_and(|ids| ids.contains(&operation.row_id))
11137 {
11138 return true;
11139 }
11140 operation.values.as_ref().is_some_and(|values| {
11141 matching_targets
11142 .iter()
11143 .any(|target| Self::local_purge_values_match(target, values))
11144 })
11145 })
11146 })
11147 .cloned()
11148 .collect::<Vec<_>>();
11149 let doomed_ids = doomed
11150 .iter()
11151 .map(|commit| commit.client_commit_id.clone())
11152 .collect::<BTreeSet<_>>();
11153 let mut batch = ChangeAccumulator::default();
11154 let mut rejections = Vec::new();
11155 for commit in &doomed {
11156 for operation in &commit.ops {
11157 batch.table(&operation.table);
11158 }
11159 let results = commit
11160 .ops
11161 .iter()
11162 .enumerate()
11163 .map(|(op_index, operation)| {
11164 let rejection = RejectionRecord {
11165 client_commit_id: commit.client_commit_id.clone(),
11166 op_index: op_index as i32,
11167 code: "client.local_data_purged".to_owned(),
11168 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
11169 retryable: false,
11170 details: None,
11171 operation: Some(CommitOperation::from(operation)),
11172 };
11173 rejections.push(rejection.clone());
11174 CommitOperationOutcome::Error { rejection }
11175 })
11176 .collect::<Vec<_>>();
11177 self.persist_commit_outcome(
11178 &commit.client_commit_id,
11179 CommitOutcomeStatus::Rejected,
11180 &results,
11181 Some(&commit.ops),
11182 )?;
11183 self.delete_outbox_persisted(&commit.client_commit_id)?;
11184 }
11185 if !doomed.is_empty() {
11186 self.outbox
11187 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
11188 self.rejections.extend(rejections);
11189 self.prune_commit_outcomes()?;
11190 self.overlay_dirty.set(true);
11191 batch.status = true;
11192 batch.rejections = true;
11193 batch.outcomes = true;
11194 }
11195
11196 let mut purged_rows = 0usize;
11197 for (table, ids) in &row_ids {
11198 if ids.is_empty() {
11199 continue;
11200 }
11201 batch.table(table);
11202 for row_id in ids {
11203 self.delete_base_row(table, row_id)?;
11204 purged_rows += 1;
11205 }
11206 }
11207 self.rebuild_overlay_if_dirty();
11208 self.delete_unreferenced_cached_blobs();
11209 self.conn
11210 .execute(
11211 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11212 rusqlite::params![meta_key, canonical_plan],
11213 )
11214 .map_err(|error| error.to_string())?;
11215 Ok((
11216 batch,
11217 LocalDataPurgeResult {
11218 already_applied: false,
11219 purged_rows,
11220 dropped_commits: doomed.len(),
11221 },
11222 ))
11223 })();
11224 let (batch, result) = match applied {
11225 Ok(value) => value,
11226 Err(error) => {
11227 self.rollback_observation("syncular_local_purge");
11228 self.outbox = prior_outbox;
11229 self.rejections.truncate(prior_rejection_count);
11230 self.overlay_dirty.set(prior_overlay_dirty);
11231 return Err(error);
11232 }
11233 };
11234 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
11235 self.rollback_observation("syncular_local_purge");
11236 self.outbox = prior_outbox;
11237 self.rejections.truncate(prior_rejection_count);
11238 self.overlay_dirty.set(prior_overlay_dirty);
11239 return Err(error);
11240 }
11241 Ok(result)
11242 }
11243
11244 pub fn rebootstrap_local_data(
11250 &mut self,
11251 input: &LocalDataRebootstrapInput,
11252 ) -> Result<LocalDataRebootstrapResult, String> {
11253 if input.rebootstrap_id.is_empty()
11254 || input.rebootstrap_id.len() > 128
11255 || !is_local_operation_code_like(&input.rebootstrap_id)
11256 {
11257 return Err(
11258 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
11259 .to_owned(),
11260 );
11261 }
11262 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
11263 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
11264 let (retained_commits, reset_subscriptions) =
11265 decode_local_rebootstrap_receipt(&persisted)?;
11266 return Ok(LocalDataRebootstrapResult {
11267 already_applied: true,
11268 retained_commits,
11269 reset_subscriptions,
11270 });
11271 }
11272 if self.stopped || self.schema_floor.is_some() {
11273 return Err(
11274 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
11275 .to_owned(),
11276 );
11277 }
11278
11279 let retained_commits = self.outbox.len();
11280 let reset_subscriptions = self.subs.len();
11281 let prior_subs = self.subs.clone();
11282 let prior_upgrading = self.upgrading;
11283 let prior_stopped = self.stopped;
11284 let prior_schema_floor = self.schema_floor.clone();
11285 let prior_overlay_dirty = self.overlay_dirty.get();
11286 let prior_sync_needed = self.sync_needed;
11287 let prior_sync_intents = self.sync_intent_queue.clone();
11288 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
11289
11290 self.begin_observation("syncular_local_rebootstrap")?;
11291 let mut batch = ChangeAccumulator::default();
11292 let applied = (|| -> Result<(), String> {
11293 self.run_schema_reset_observed(&mut batch, false, false)?;
11294 self.conn
11295 .execute(
11296 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11297 rusqlite::params![meta_key, receipt],
11298 )
11299 .map_err(|error| error.to_string())?;
11300 self.sync_needed = true;
11301 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11302 batch.status = true;
11303 Ok(())
11304 })();
11305 if let Err(error) = applied {
11306 self.rollback_observation("syncular_local_rebootstrap");
11307 self.subs = prior_subs;
11308 self.upgrading = prior_upgrading;
11309 self.stopped = prior_stopped;
11310 self.schema_floor = prior_schema_floor;
11311 self.overlay_dirty.set(prior_overlay_dirty);
11312 self.sync_needed = prior_sync_needed;
11313 self.sync_intent_queue = prior_sync_intents;
11314 return Err(error);
11315 }
11316 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
11317 self.rollback_observation("syncular_local_rebootstrap");
11318 self.subs = prior_subs;
11319 self.upgrading = prior_upgrading;
11320 self.stopped = prior_stopped;
11321 self.schema_floor = prior_schema_floor;
11322 self.overlay_dirty.set(prior_overlay_dirty);
11323 self.sync_needed = prior_sync_needed;
11324 self.sync_intent_queue = prior_sync_intents;
11325 return Err(error);
11326 }
11327
11328 Ok(LocalDataRebootstrapResult {
11329 already_applied: false,
11330 retained_commits,
11331 reset_subscriptions,
11332 })
11333 }
11334
11335 fn purge_scope_rows(
11340 &mut self,
11341 table_name: &str,
11342 effective: &[(String, Vec<String>)],
11343 ) -> Result<(), ()> {
11344 if effective.is_empty() {
11345 return Ok(());
11346 }
11347 let table = self.schema.table(table_name).ok_or(())?.clone();
11348 let mut clauses = Vec::new();
11349 let mut params: Vec<SqlValue> = Vec::new();
11350 for (variable, values) in effective {
11351 let column = table.scope_column(variable).ok_or(())?;
11352 let placeholders: Vec<String> = values
11353 .iter()
11354 .map(|v| {
11355 params.push(SqlValue::Text(v.clone()));
11356 "?".to_owned()
11357 })
11358 .collect();
11359 clauses.push(format!(
11360 "{} IN ({})",
11361 quote_ident(column),
11362 placeholders.join(", ")
11363 ));
11364 }
11365 let sql = format!(
11366 "DELETE FROM {} WHERE {}",
11367 base_table(table_name),
11368 clauses.join(" AND ")
11369 );
11370 self.overlay_dirty.set(true);
11371 self.conn
11372 .execute(&sql, rusqlite::params_from_iter(¶ms))
11373 .map_err(|_| ())?;
11374 self.conn
11375 .execute(
11376 &format!(
11377 "DELETE FROM {} WHERE {}",
11378 visible_table(table_name),
11379 clauses.join(" AND ")
11380 ),
11381 rusqlite::params_from_iter(¶ms),
11382 )
11383 .map_err(|_| ())?;
11384 Ok(())
11385 }
11386
11387 fn drop_doomed_outbox(
11390 &mut self,
11391 table_name: &str,
11392 effective: &[(String, Vec<String>)],
11393 ) -> Result<bool, String> {
11394 if effective.is_empty() {
11395 return Ok(false);
11396 }
11397 let Some(table) = self.schema.table(table_name).cloned() else {
11398 return Ok(false);
11399 };
11400 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
11401 for (variable, values) in effective {
11402 match table.scope_column(variable) {
11403 Some(column) => mappings.push((column, values)),
11404 None => return Ok(false), }
11406 }
11407 let doomed: Vec<OutboxCommit> = self
11408 .outbox
11409 .iter()
11410 .filter(|commit| {
11411 commit.ops.iter().any(|op| {
11412 op.upsert
11413 && op.table == table_name
11414 && op.values.as_ref().is_some_and(|values| {
11415 mappings
11416 .iter()
11417 .all(|(column, allowed)| match values.get(*column) {
11418 Some(Value::String(s)) => allowed.contains(s),
11419 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
11420 _ => false,
11421 })
11422 })
11423 })
11424 })
11425 .cloned()
11426 .collect();
11427 if doomed.is_empty() {
11428 return Ok(false);
11429 }
11430 let mut rejections = Vec::new();
11431 for commit in &doomed {
11432 let results = commit
11433 .ops
11434 .iter()
11435 .enumerate()
11436 .map(|(op_index, operation)| {
11437 let rejection = RejectionRecord {
11438 client_commit_id: commit.client_commit_id.clone(),
11439 op_index: op_index as i32,
11440 code: "sync.scope_revoked".to_owned(),
11441 message: "the commit was dropped because its effective scope was revoked"
11442 .to_owned(),
11443 retryable: false,
11444 details: None,
11445 operation: Some(CommitOperation::from(operation)),
11446 };
11447 rejections.push(rejection.clone());
11448 CommitOperationOutcome::Error { rejection }
11449 })
11450 .collect::<Vec<_>>();
11451 self.persist_commit_outcome(
11452 &commit.client_commit_id,
11453 CommitOutcomeStatus::Rejected,
11454 &results,
11455 Some(&commit.ops),
11456 )?;
11457 self.delete_outbox_persisted(&commit.client_commit_id)?;
11458 }
11459 self.prune_commit_outcomes()?;
11460 let doomed_ids = doomed
11461 .iter()
11462 .map(|commit| commit.client_commit_id.as_str())
11463 .collect::<BTreeSet<_>>();
11464 self.outbox
11465 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
11466 self.rejections.extend(rejections);
11467 self.overlay_dirty.set(true);
11468 Ok(true)
11469 }
11470
11471 pub fn upload_blob(
11478 &mut self,
11479 bytes: &[u8],
11480 media_type: Option<String>,
11481 name: Option<String>,
11482 ) -> Result<Value, String> {
11483 let blob_id = blob_id_for(bytes);
11484 let now = self.clock_now_ms();
11485 let tx = self.conn.transaction().map_err(|e| e.to_string())?;
11486 tx.execute(
11487 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
11488 ON CONFLICT(blob_id) DO NOTHING",
11489 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
11490 )
11491 .map_err(|e| e.to_string())?;
11492 tx.execute(
11493 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
11494 rusqlite::params![blob_id, media_type, now],
11495 )
11496 .map_err(|e| e.to_string())?;
11497 tx.commit().map_err(|e| e.to_string())?;
11498 self.enforce_blob_cache_cap();
11499 let mut obj = Map::new();
11500 obj.insert("blobId".to_owned(), Value::from(blob_id));
11501 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
11502 if let Some(mt) = media_type {
11503 obj.insert("mediaType".to_owned(), Value::from(mt));
11504 }
11505 if let Some(n) = name {
11506 obj.insert("name".to_owned(), Value::from(n));
11507 }
11508 Ok(Value::Object(obj))
11509 }
11510
11511 pub fn fetch_blob_bytes(
11515 &mut self,
11516 transport: &mut dyn Transport,
11517 blob_id_or_ref: &str,
11518 ) -> Result<FetchedBlob, (String, String)> {
11519 let simple = |m: String| ("client.failed".to_owned(), m);
11520 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
11521 blob_id_or_ref.to_owned()
11522 } else {
11523 let value: Value = serde_json::from_str(blob_id_or_ref)
11524 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
11525 value
11526 .get("blobId")
11527 .and_then(Value::as_str)
11528 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
11529 .to_owned()
11530 };
11531 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
11532 return Ok(cached);
11533 }
11534 #[cfg(feature = "bench-internals")]
11540 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
11541 let bytes = match transport
11542 .blob_download(&blob_id)
11543 .map_err(|e| (e.code, e.message))?
11544 {
11545 BlobDownload::Bytes(bytes) => bytes,
11546 BlobDownload::Url {
11547 url,
11548 url_expires_at_ms,
11549 } => {
11550 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
11552 return Err((
11553 "sync.segment_expired".to_owned(),
11554 format!(
11555 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
11556 ),
11557 ));
11558 }
11559 transport
11560 .fetch_blob_url(&url)
11561 .map_err(|e| (e.code, e.message))?
11562 }
11563 };
11564 #[cfg(feature = "bench-internals")]
11565 drop(download_phase);
11566 #[cfg(feature = "bench-internals")]
11567 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
11568 if blob_id_for(&bytes) != blob_id {
11570 return Err(simple(format!(
11571 "blob content address mismatch for {blob_id}"
11572 )));
11573 }
11574 #[cfg(feature = "bench-internals")]
11575 drop(validate_phase);
11576 #[cfg(feature = "bench-internals")]
11577 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
11578 let now = self.clock_now_ms();
11579 self.conn
11580 .execute(
11581 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
11582 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
11583 )
11584 .map_err(|e| simple(e.to_string()))?;
11585 self.conn
11586 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
11587 .map_err(|e| simple(e.to_string()))?;
11588 self.enforce_blob_cache_cap();
11589 #[cfg(feature = "bench-internals")]
11590 drop(insert_phase);
11591 self.get_cached_blob_bytes(&blob_id)
11592 .map_err(simple)?
11593 .ok_or_else(|| simple("blob cache write failed".to_owned()))
11594 }
11595
11596 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
11597 #[cfg(feature = "bench-internals")]
11598 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
11599 let mut stmt = self
11600 .conn
11601 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
11602 .map_err(|e| e.to_string())?;
11603 let mut rows = stmt
11604 .query(rusqlite::params![blob_id])
11605 .map_err(|e| e.to_string())?;
11606 if let Some(row) = rows.next().map_err(|e| e.to_string())? {
11607 let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
11608 let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
11609 let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
11610 return Ok(Some(FetchedBlob {
11611 blob_id: blob_id.to_owned(),
11612 byte_length,
11613 bytes,
11614 media_type,
11615 }));
11616 }
11617 Ok(None)
11618 }
11619
11620 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
11622 let pending: Vec<(String, Option<String>)> = {
11623 let mut stmt = self
11624 .conn
11625 .prepare(
11626 "SELECT blob_id, media_type FROM (
11627 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
11628 UNION ALL
11629 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
11630 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
11631 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
11632 ) ORDER BY created_at_ms, blob_id",
11633 )
11634 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11635 let rows = stmt
11636 .query_map([], |row| {
11637 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
11638 })
11639 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11640 rows.collect::<Result<_, _>>()
11641 .map_err(|error| match error {
11642 rusqlite::Error::InvalidColumnType(..)
11643 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
11644 "sync.local_corrupt",
11645 "Pending blob upload metadata is invalid",
11646 ),
11647 _ => TransportError::new("client.failed", error.to_string()),
11648 })?
11649 };
11650 for (blob_id, media_type) in pending {
11651 let stored: Option<(SqlValue, SqlValue)> = self
11652 .conn
11653 .query_row(
11654 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
11655 rusqlite::params![blob_id],
11656 |row| Ok((row.get(0)?, row.get(1)?)),
11657 )
11658 .optional()
11659 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11660 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
11661 return Err(TransportError::new(
11662 "sync.local_corrupt",
11663 "Pending blob upload body is missing or corrupt",
11664 ));
11665 };
11666 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
11667 return Err(TransportError::new(
11668 "sync.local_corrupt",
11669 "Pending blob upload body is missing or corrupt",
11670 ));
11671 }
11672 self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
11673 self.conn
11674 .execute(
11675 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
11676 rusqlite::params![blob_id],
11677 )
11678 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11679 }
11680 Ok(())
11681 }
11682
11683 fn upload_one(
11690 &self,
11691 transport: &mut dyn Transport,
11692 blob_id: &str,
11693 bytes: &[u8],
11694 media_type: Option<&str>,
11695 ) -> Result<(), TransportError> {
11696 match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
11697 BlobUploadGrant::Present => return Ok(()), BlobUploadGrant::Url {
11699 url,
11700 url_expires_at_ms,
11701 } => {
11702 let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
11703 if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
11704 return Ok(());
11705 }
11706 }
11708 BlobUploadGrant::None => {
11709 }
11711 }
11712 transport.blob_upload(blob_id, bytes, media_type)
11713 }
11714
11715 fn visible_blob_ids_sql(&self) -> String {
11716 let mut selects = Vec::new();
11717 for table in &self.schema.tables {
11718 for column in table
11719 .columns
11720 .iter()
11721 .filter(|column| column.ty == ColumnType::BlobRef)
11722 {
11723 let identifier = quote_ident(&column.name);
11724 selects.push(format!(
11725 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
11726 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
11727 AND json_type({identifier}, '$.blobId') = 'text'",
11728 quote_ident(&table.name),
11729 ));
11730 }
11731 }
11732 if selects.is_empty() {
11733 "SELECT NULL AS blob_id WHERE 0".to_owned()
11734 } else {
11735 selects.join(" UNION ")
11736 }
11737 }
11738
11739 fn enforce_blob_cache_cap(&self) {
11742 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
11743 return;
11744 };
11745 let mut total: i64 = self
11746 .conn
11747 .query_row(
11748 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
11749 [],
11750 |row| row.get(0),
11751 )
11752 .unwrap_or(0);
11753 if total <= max_bytes {
11754 return;
11755 }
11756 let query = format!(
11757 "SELECT blob_id, byte_length FROM _syncular_blobs
11758 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11759 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11760 AND blob_id NOT IN ({})
11761 ORDER BY created_at_ms ASC, blob_id ASC",
11762 self.visible_blob_ids_sql(),
11763 );
11764 let candidates: Vec<(String, i64)> = {
11765 let Ok(mut stmt) = self.conn.prepare(&query) else {
11766 return;
11767 };
11768 let Ok(rows) = stmt.query_map([], |row| {
11769 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
11770 }) else {
11771 return;
11772 };
11773 rows.filter_map(Result::ok).collect()
11774 };
11775 for (blob_id, byte_length) in candidates {
11776 if total <= max_bytes {
11777 break;
11778 }
11779 let _ = self.conn.execute(
11780 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
11781 rusqlite::params![blob_id],
11782 );
11783 total -= byte_length;
11784 }
11785 }
11786
11787 fn delete_unreferenced_cached_blobs(&mut self) {
11788 #[cfg(feature = "bench-internals")]
11789 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
11790 if !self.schema_has_blobs() {
11791 return;
11792 }
11793 self.rebuild_overlay_if_dirty();
11794 let _ = self.conn.execute(
11795 &format!(
11796 "DELETE FROM _syncular_blobs
11797 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11798 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11799 AND blob_id NOT IN ({})",
11800 self.visible_blob_ids_sql(),
11801 ),
11802 [],
11803 );
11804 }
11805
11806 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
11811 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
11812 return sql.clone();
11813 }
11814 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
11815 columns.push(quote_ident("_syncular_version"));
11816 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
11817 let primary_key = quote_ident(&table.primary_key);
11818 let updates = columns
11819 .iter()
11820 .filter(|column| **column != primary_key)
11821 .map(|column| format!("{column}=excluded.{column}"))
11822 .collect::<Vec<_>>()
11823 .join(", ");
11824 let sql = format!(
11825 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
11826 columns.join(", "),
11827 placeholders.join(", ")
11828 );
11829 self.insert_sql
11830 .borrow_mut()
11831 .insert(full_table.to_owned(), sql.clone());
11832 sql
11833 }
11834
11835 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
11836 self.overlay_dirty.set(true);
11837 self.write_row(&base_table(table_name), table_name, row, version)
11838 }
11839
11840 fn write_row(
11841 &self,
11842 full_table: &str,
11843 table_name: &str,
11844 row: &Row,
11845 version: i64,
11846 ) -> Result<(), String> {
11847 #[cfg(feature = "bench-internals")]
11848 let _phase = self.benchmark_phases.start(Phase::RowWrite);
11849 let table = self
11850 .schema
11851 .table(table_name)
11852 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11853 let sql = self.insert_row_sql(full_table, table);
11854 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11855 let params = row
11856 .iter()
11857 .map(RowParam::Cell)
11858 .chain(std::iter::once(RowParam::Version(version)));
11859 stmt.execute(rusqlite::params_from_iter(params))
11860 .map_err(|e| e.to_string())?;
11861 Ok(())
11862 }
11863
11864 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
11865 let table = self
11866 .schema
11867 .table(table_name)
11868 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11869 self.overlay_dirty.set(true);
11870 let sql = format!(
11871 "DELETE FROM {} WHERE {}",
11872 base_table(table_name),
11873 row_id_predicate(table)
11874 );
11875 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11876 stmt.execute(rusqlite::params![row_id])
11877 .map_err(|e| e.to_string())?;
11878 Ok(())
11879 }
11880
11881 fn rebuild_overlay_if_dirty(&mut self) {
11884 if self.overlay_dirty.get() {
11885 self.rebuild_overlay();
11886 }
11887 }
11888
11889 fn rebuild_overlay(&mut self) {
11893 #[cfg(feature = "bench-internals")]
11894 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
11895 #[cfg(test)]
11896 self.overlay_rebuild_count
11897 .set(self.overlay_rebuild_count.get() + 1);
11898 self.exec("SAVEPOINT syncular_overlay");
11899 for table in self.schema.tables.clone() {
11900 for index in &table.fts_indexes {
11901 let _ = self.drop_fts_triggers(index);
11902 }
11903 let visible = visible_table(&table.name);
11904 let base = base_table(&table.name);
11905 self.exec(&format!("DELETE FROM {visible}"));
11906 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
11907 }
11908 for commit in &self.outbox {
11909 self.apply_outbox_ops(&commit.ops);
11910 }
11911 for table in self.schema.tables.clone() {
11912 for index in &table.fts_indexes {
11913 let _ = self.rebuild_fts_projection(&table, index);
11914 let _ = self.create_fts_triggers(&table, index);
11915 }
11916 }
11917 self.exec("RELEASE syncular_overlay");
11918 self.overlay_dirty.set(false);
11919 }
11920
11921 fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
11926 #[cfg(feature = "bench-internals")]
11927 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
11928 for op in ops {
11929 #[cfg(feature = "bench-internals")]
11930 if let Some(phase) = &mut phase {
11931 phase.unit();
11932 }
11933 let _ = self.apply_outbox_op(op);
11934 }
11935 }
11936
11937 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
11938 let Some(table) = self.schema.table(&op.table) else {
11939 return Ok(());
11940 };
11941 let visible = visible_table(&table.name);
11942 if !op.upsert {
11943 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
11944 self.conn
11945 .prepare_cached(&sql)
11946 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
11947 .map_err(|e| e.to_string())?;
11948 return Ok(());
11949 }
11950 let Some(values) = op.values.as_ref() else {
11951 return Ok(());
11952 };
11953 let full = table
11958 .columns
11959 .iter()
11960 .all(|column| values.contains_key(&column.name));
11961 let local = self.visible_row(table, &op.row_id);
11962 if local.is_none() && !full {
11963 return Ok(());
11964 }
11965 let mut row: Row = Vec::with_capacity(table.columns.len());
11966 for (index, column) in table.columns.iter().enumerate() {
11967 let incoming = match values.get(&column.name) {
11968 Some(raw) => json_to_column_value(column, Some(raw)),
11969 None => Ok(local
11970 .as_ref()
11971 .and_then(|(values, _)| values.get(index).cloned())
11972 .flatten()),
11973 };
11974 match incoming {
11975 Ok(value) => row.push(value),
11976 Err(_) => return Ok(()),
11977 }
11978 }
11979 let version = local.as_ref().map_or(-1, |(_, version)| *version);
11980 self.write_row(&visible, &table.name, &row, version)
11981 .map_err(|error| {
11982 if error.starts_with("UNIQUE constraint failed:") {
11983 "sync.constraint_violation: local write violates a unique constraint".to_owned()
11984 } else {
11985 error
11986 }
11987 })
11988 }
11989
11990 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
11993 let sql = format!(
11994 "SELECT * FROM {} WHERE {}",
11995 visible_table(&table.name),
11996 row_id_predicate(table)
11997 );
11998 let mut stmt = self.conn.prepare_cached(&sql).ok()?;
11999 let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
12000 let row = rows.next().ok()??;
12001 let mut values = Vec::with_capacity(table.columns.len());
12002 for (index, column) in table.columns.iter().enumerate() {
12003 let raw = row.get_ref(index).ok()?;
12004 let json = sql_ref_to_json(column, raw);
12005 values.push(json_to_column_value(column, Some(&json)).ok()?);
12006 }
12007 let version: i64 = row.get(table.columns.len()).ok()?;
12008 Some((values, version))
12009 }
12010
12011 fn exec(&self, sql: &str) {
12012 let _ = self.conn.execute_batch(sql);
12013 }
12014
12015 #[must_use]
12019 pub fn realtime_policy(&self) -> RealtimePolicy {
12020 self.realtime_policy
12021 }
12022
12023 #[must_use]
12025 pub fn realtime_state(&self) -> RealtimeState {
12026 self.realtime_state
12027 }
12028
12029 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
12032 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
12033 return Err(
12034 "sync.invalid_request: disconnect realtime before changing the realtime policy"
12035 .to_owned(),
12036 );
12037 }
12038 self.realtime_policy = policy;
12039 match policy {
12040 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
12041 RealtimePolicy::Required | RealtimePolicy::Optional
12042 if self.realtime_state == RealtimeState::Disabled =>
12043 {
12044 self.set_realtime_state(RealtimeState::Disconnected, None);
12045 }
12046 RealtimePolicy::Required | RealtimePolicy::Optional => {}
12047 }
12048 Ok(())
12049 }
12050
12051 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
12054 self.realtime_state = state;
12055 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
12056 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
12057 self.realtime_reason_code = None;
12058 self.realtime_retry_delay_ms = None;
12059 }
12060 }
12061
12062 #[must_use]
12063 fn realtime_connected(&self) -> bool {
12064 self.realtime_state == RealtimeState::Connected
12065 }
12066
12067 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
12068 if self.realtime_policy == RealtimePolicy::Off {
12069 return Err(
12070 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
12071 );
12072 }
12073 if self.realtime_connected() {
12074 return Ok(());
12075 }
12076 self.realtime_state = RealtimeState::Connecting;
12077 match transport.realtime_connect_for_client(&self.client_id) {
12078 Ok(()) => {
12079 self.set_realtime_state(RealtimeState::Connected, None);
12080 Ok(())
12081 }
12082 Err(error) => {
12083 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
12084 Err(format!("{}: {}", error.code, error.message))
12085 }
12086 }
12087 }
12088
12089 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
12090 if self.realtime_state == RealtimeState::Disconnected
12093 || self.realtime_state == RealtimeState::Disabled
12094 {
12095 return;
12096 }
12097 let _ = transport.realtime_close();
12098 self.set_realtime_state(
12099 if self.realtime_policy == RealtimePolicy::Off {
12100 RealtimeState::Disabled
12101 } else {
12102 RealtimeState::Disconnected
12103 },
12104 None,
12105 );
12106 self.presence.clear(); }
12108
12109 pub fn set_presence(
12115 &mut self,
12116 transport: &mut dyn Transport,
12117 scope_key: &str,
12118 doc: Option<&Value>,
12119 ) -> Result<(), String> {
12120 if !self.realtime_connected() {
12121 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
12122 }
12123 let text = encode_presence_publish(scope_key, doc);
12124 transport
12125 .realtime_send(&text)
12126 .map_err(|e| format!("{}: {}", e.code, e.message))
12127 }
12128
12129 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
12131 self.presence
12132 .get(scope_key)
12133 .map(|peers| peers.values().cloned().collect())
12134 .unwrap_or_default()
12135 }
12136
12137 fn apply_presence(
12139 &mut self,
12140 scope_key: String,
12141 kind: Option<PresenceKind>,
12142 actor_id: Option<String>,
12143 client_id: Option<String>,
12144 doc: Option<Value>,
12145 error: Option<String>,
12146 ) {
12147 if error.is_some() {
12150 return;
12151 }
12152 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
12153 return;
12154 };
12155 let peer_key = format!("{actor_id} {client_id}");
12156 match kind {
12157 PresenceKind::Leave => {
12158 if let Some(peers) = self.presence.get_mut(&scope_key) {
12159 peers.remove(&peer_key);
12160 if peers.is_empty() {
12161 self.presence.remove(&scope_key);
12162 }
12163 }
12164 }
12165 _ => {
12166 let doc = match doc {
12167 Some(Value::Object(_)) => doc.unwrap(),
12168 _ => return,
12169 };
12170 self.presence.entry(scope_key).or_default().insert(
12171 peer_key,
12172 PresencePeer {
12173 actor_id,
12174 client_id,
12175 doc,
12176 },
12177 );
12178 }
12179 }
12180 }
12181
12182 pub fn on_realtime_text(&mut self, text: &str) {
12184 match parse_control(text) {
12185 Ok(ControlMessage::Hello { requires_sync, .. }) => {
12186 if requires_sync {
12187 self.set_sync_needed(true, true);
12189 }
12190 }
12191 Ok(ControlMessage::Presence {
12192 scope_key,
12193 kind,
12194 actor_id,
12195 client_id,
12196 doc,
12197 error,
12198 ..
12199 }) => {
12200 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
12201 }
12202 Ok(ControlMessage::Wake { .. }) => {
12203 self.set_sync_needed(true, true);
12205 }
12206 _ => {}
12207 }
12208 }
12209
12210 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
12213 if self.stopped {
12214 return;
12215 }
12216 #[cfg(feature = "bench-internals")]
12217 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
12218 let message = match decode_message(bytes) {
12219 Ok(m) if m.msg_kind == MsgKind::Response => m,
12220 _ => {
12221 self.set_sync_needed(true, true);
12222 return;
12223 }
12224 };
12225 #[cfg(feature = "bench-internals")]
12226 drop(decode_phase);
12227 let mut frames = message.frames.into_iter();
12228 let mut applied_cursor: Option<i64> = None;
12229 let mut any_covered = false;
12230 let mut dropped = false;
12231 while let Some(frame) = frames.next() {
12232 let Frame::SubStart {
12233 id,
12234 status,
12235 effective_scopes,
12236 ..
12237 } = frame
12238 else {
12239 continue;
12240 };
12241 let mut body = Vec::new();
12242 let mut next_cursor: Option<i64> = None;
12243 for inner in frames.by_ref() {
12244 match inner {
12245 Frame::SubEnd {
12246 next_cursor: nc, ..
12247 } => {
12248 next_cursor = Some(nc);
12249 break;
12250 }
12251 Frame::Unknown { .. } => {}
12252 other => body.push(other),
12253 }
12254 }
12255 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
12256 dropped = true;
12257 continue;
12258 };
12259 let sub = &self.subs[sub_index];
12260 if status != SubStatus::Active
12263 || sub.state != SubState::Active
12264 || sub.bootstrap_state.is_some()
12265 || !sub.synced_once
12266 {
12267 dropped = true;
12268 continue;
12269 }
12270 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
12271 any_covered = true;
12273 continue;
12274 }
12275 let previous_effective = self.subs[sub_index].effective.clone();
12276 let previous_cursor = self.subs[sub_index].cursor;
12277 self.subs[sub_index].effective = Some(effective_scopes);
12278 let mut failed = false;
12279 for inner in body {
12280 match inner {
12281 Frame::Commit {
12282 tables, changes, ..
12283 } => {
12284 if self.apply_commit_frame(&tables, &changes).is_err() {
12285 failed = true;
12286 break;
12287 }
12288 }
12289 _ => {
12290 failed = true;
12291 break;
12292 }
12293 }
12294 }
12295 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
12296 self.apply_sub_end(sub_index, cursor, None)
12297 .ok()
12298 .map(|()| cursor)
12299 });
12300 let Some(next_cursor) = committed else {
12301 self.subs[sub_index].effective = previous_effective;
12302 self.subs[sub_index].cursor = previous_cursor;
12303 dropped = true;
12304 continue;
12305 };
12306 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
12307 }
12308 if let Some(cursor) = applied_cursor {
12309 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12311 let _ = transport.realtime_send(&ack);
12312 } else if !any_covered || dropped {
12313 self.set_sync_needed(true, true);
12315 }
12316 }
12317
12318 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
12322 if !self.realtime_connected() {
12323 return;
12324 }
12325 let floor = self
12326 .subs
12327 .iter()
12328 .filter(|s| {
12329 s.state == SubState::Active
12330 && s.bootstrap_state.is_none()
12331 && s.synced_once
12332 && s.cursor >= 0
12333 })
12334 .map(|s| s.cursor)
12335 .min();
12336 if let Some(cursor) = floor {
12337 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12338 let _ = transport.realtime_send(&ack);
12339 }
12340 }
12341}
12342
12343#[cfg(test)]
12344mod previous_version_wiring_tests {
12345 use super::*;
12346 use serde_json::{json, Value};
12347
12348 fn previous_version_schema(version: i32) -> Value {
12349 json!({
12350 "version": version,
12351 "tables": [{
12352 "name": "tasks",
12353 "primaryKey": "id",
12354 "columns": [
12355 { "name": "id", "type": "string", "nullable": false },
12356 { "name": "note", "type": "string", "nullable": true }
12357 ],
12358 "scopes": []
12359 }]
12360 })
12361 }
12362
12363 fn previous_version_limits() -> crate::api::ClientLimits {
12364 crate::api::ClientLimits {
12365 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
12366 enabled: true,
12367 ..Default::default()
12368 }),
12369 ..Default::default()
12370 }
12371 }
12372
12373 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
12375 previous_version_limits()
12376 .previous_version_context
12377 .expect("configured")
12378 }
12379
12380 fn recapture_previous_version(client: &SyncClient) {
12383 let replica_path = client
12384 .previous_version_replica_path()
12385 .expect("file-backed replica");
12386 let outcome = crate::previous_version::capture_previous_version_from_replica(
12387 &client.conn,
12388 &replica_path,
12389 2,
12390 2,
12391 &previous_version_config(),
12392 1,
12393 )
12394 .expect("capture");
12395 assert!(matches!(
12396 outcome,
12397 crate::previous_version::CaptureOutcome::Captured(_)
12398 ));
12399 }
12400
12401 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
12402 Subscription {
12403 id: "wiring-sub".to_owned(),
12404 table: "tasks".to_owned(),
12405 requested: Vec::new(),
12406 params: None,
12407 cursor,
12408 bootstrap_state: bootstrap_state.map(str::to_owned),
12409 state,
12410 reason_code: None,
12411 effective: None,
12412 synced_once: false,
12413 }
12414 }
12415
12416 #[test]
12417 fn previous_version_read_reasons_come_from_live_client_state() {
12418 use crate::previous_version::{
12419 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
12420 };
12421 let path = std::env::temp_dir()
12422 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
12423 .to_string_lossy()
12424 .into_owned();
12425 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
12426 {
12427 let mut setup = SyncClient::open_path(
12428 "wiring".to_owned(),
12429 &previous_version_schema(1),
12430 previous_version_limits(),
12431 &path,
12432 )
12433 .expect("v1 install");
12434 setup
12435 .mutate(vec![Mutation::Upsert {
12436 table: "tasks".to_owned(),
12437 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
12438 base_version: None,
12439 }])
12440 .expect("seed a row");
12441 }
12442 let mut client = SyncClient::open_path(
12443 "wiring".to_owned(),
12444 &previous_version_schema(2),
12445 previous_version_limits(),
12446 &path,
12447 )
12448 .expect("v2 bump captures");
12449 assert!(std::path::Path::new(&container).exists());
12450 let spec = PreviousVersionReadSpec {
12451 table: "tasks".to_owned(),
12452 ..Default::default()
12453 };
12454
12455 recapture_previous_version(&client);
12457 client.lease_state = Some(LeaseState {
12458 lease_id: None,
12459 expires_at_ms: None,
12460 error_code: Some("lease.expired".to_owned()),
12461 });
12462 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12463 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
12464 assert!(!snapshot.available);
12465 assert!(!std::path::Path::new(&container).exists());
12466
12467 recapture_previous_version(&client);
12469 client.set_now_ms(1_000);
12470 client.lease_state = Some(LeaseState {
12471 lease_id: Some("lease".to_owned()),
12472 expires_at_ms: Some(1_000),
12473 error_code: None,
12474 });
12475 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12476 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
12477 assert!(!std::path::Path::new(&container).exists());
12478 client.lease_state = None;
12479
12480 recapture_previous_version(&client);
12482 client.subs.push(subscription(SubState::Revoked, 0, None));
12483 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12484 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
12485 assert!(!std::path::Path::new(&container).exists());
12486 client.subs.clear();
12487
12488 recapture_previous_version(&client);
12491 client
12492 .subs
12493 .push(subscription(SubState::Active, 0, Some("resume")));
12494 assert!(!client.previous_version_coverage_complete());
12495 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12496 assert!(snapshot.available);
12497 assert_eq!(snapshot.previous_version, Some(2));
12498 assert_eq!(snapshot.rows.len(), 1);
12499
12500 recapture_previous_version(&client);
12501 client.subs[0].bootstrap_state = None;
12502 assert!(client.previous_version_coverage_complete());
12503 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12504 assert_eq!(
12505 snapshot.reason,
12506 Some(PreviousVersionReason::CoverageComplete)
12507 );
12508 assert!(!std::path::Path::new(&container).exists());
12509 client.subs.clear();
12510
12511 assert!(!client.previous_version_coverage_complete());
12513
12514 drop(client);
12515 let _ = std::fs::remove_file(&path);
12516 }
12517
12518 #[test]
12525 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
12526 let path = std::env::temp_dir()
12527 .join(format!(
12528 "syncular-prev-lifetime-{}.db",
12529 uuid::Uuid::new_v4()
12530 ))
12531 .to_string_lossy()
12532 .into_owned();
12533 let container = format!(
12534 "{path}{}",
12535 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
12536 );
12537 let exists = || std::path::Path::new(&container).exists();
12538 {
12539 let mut setup = SyncClient::open_path(
12540 "lifetime".to_owned(),
12541 &previous_version_schema(1),
12542 previous_version_limits(),
12543 &path,
12544 )
12545 .expect("v1 install");
12546 setup
12547 .mutate(vec![Mutation::Upsert {
12548 table: "tasks".to_owned(),
12549 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
12550 base_version: None,
12551 }])
12552 .expect("seed a row");
12553 }
12554 let mut client = SyncClient::open_path(
12555 "lifetime".to_owned(),
12556 &previous_version_schema(2),
12557 previous_version_limits(),
12558 &path,
12559 )
12560 .expect("v2 bump captures");
12561 client.set_now_ms(1_000);
12564 assert!(exists());
12565
12566 client.lease_state = Some(LeaseState {
12568 lease_id: None,
12569 expires_at_ms: None,
12570 error_code: Some("lease.expired".to_owned()),
12571 });
12572 client.previous_version_lifetime_check();
12573 assert!(!exists());
12574 client.lease_state = None;
12575
12576 recapture_previous_version(&client);
12579 client.subs.push(subscription(SubState::Active, 0, None));
12580 client.previous_version_lifetime_check();
12581 assert!(!exists());
12582 client.subs.clear();
12583
12584 recapture_previous_version(&client);
12586 client.subs.push(subscription(SubState::Revoked, 0, None));
12587 client.previous_version_lifetime_check();
12588 assert!(!exists());
12589 client.subs.clear();
12590
12591 recapture_previous_version(&client);
12594 client
12595 .subs
12596 .push(subscription(SubState::Active, 0, Some("resume")));
12597 client.previous_version_lifetime_check();
12598 assert!(exists());
12599 client.subs.clear();
12600
12601 drop(client);
12602 let _ = std::fs::remove_file(&path);
12603 let _ = std::fs::remove_file(&container);
12604 let _ = std::fs::remove_file(format!("{container}-wal"));
12605 let _ = std::fs::remove_file(format!("{container}-shm"));
12606 }
12607}