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, DiagnosticRoundCounters, DiagnosticSubscription, FetchedBlob, LeaseState,
35 LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget, LocalDataRebootstrapInput,
36 LocalDataRebootstrapResult, Mutation, PresencePeer, PreviousVersionStatus, QueryRow,
37 QuerySnapshot, QueryValue, RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput,
38 RowState, SchemaFloor, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport,
39 SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage, WindowState,
40 WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
41};
42#[cfg(feature = "bench-internals")]
43use crate::bench::{Phase, Recorder};
44use crate::previous_version::{
45 capture_previous_version_from_replica, first_incompatibility,
46 reconcile_previous_version_at_boot, set_local_schema_descriptor,
47 sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
48 PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
49};
50use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
51use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
52use crate::values::{
53 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
54 encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
55 normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
56};
57
58const DEFAULT_ACCEPT: u8 = 0b0111;
62const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
63const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
64const ACCEPT_SQLITE: u8 = 1 << 2;
65const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
66const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
67
68pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
70const LOCAL_REVISION_KEY: &str = "localRevision";
71const CLIENT_ID_KEY: &str = "clientId";
72const LEASE_STATE_KEY: &str = "leaseState";
73const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
74const LOG_EPOCH_KEY: &str = "logEpoch";
75const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
80const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
81const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
82const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
85const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
88
89#[derive(Debug, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase", deny_unknown_fields)]
91struct PersistedLocalDataRebootstrapReceipt {
92 version: u8,
93 retained_commits: u64,
94 reset_subscriptions: u64,
95}
96
97fn invalid_local_rebootstrap_receipt() -> String {
98 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
99}
100
101fn encode_local_rebootstrap_receipt(
102 retained_commits: usize,
103 reset_subscriptions: usize,
104) -> Result<String, String> {
105 let retained_commits =
106 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
107 let reset_subscriptions =
108 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
109 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
110 return Err(invalid_local_rebootstrap_receipt());
111 }
112 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
113 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
114 retained_commits,
115 reset_subscriptions,
116 })
117 .map_err(|_| invalid_local_rebootstrap_receipt())
118}
119
120fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
121 if value == "v1" {
123 return Ok((0, 0));
124 }
125 let receipt: PersistedLocalDataRebootstrapReceipt =
126 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
127 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
128 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
129 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
130 {
131 return Err(invalid_local_rebootstrap_receipt());
132 }
133 Ok((
134 usize::try_from(receipt.retained_commits)
135 .map_err(|_| invalid_local_rebootstrap_receipt())?,
136 usize::try_from(receipt.reset_subscriptions)
137 .map_err(|_| invalid_local_rebootstrap_receipt())?,
138 ))
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142enum SubState {
143 Active,
144 Revoked,
145 Failed,
146}
147
148#[cfg(test)]
149mod observation_tests {
150 use super::*;
151 use crate::native_transport::HostTransport;
152 use crate::values::encode_row_json;
153 use serde_json::json;
154
155 fn client() -> SyncClient {
156 SyncClient::new(
157 "retry-test".to_owned(),
158 &json!({
159 "version": 1,
160 "tables": [{
161 "name": "tasks",
162 "primaryKey": "id",
163 "columns": [
164 { "name": "id", "type": "string", "nullable": false },
165 { "name": "project_id", "type": "string", "nullable": false }
166 ],
167 "scopes": [{ "pattern": "project:{project_id}" }]
168 }]
169 }),
170 ClientLimits::default(),
171 )
172 .expect("test client")
173 }
174
175 #[cfg(feature = "e2ee")]
176 #[test]
177 fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
178 let schema = json!({
179 "version": 1,
180 "tables": [{
181 "name": "secrets",
182 "primaryKey": "id",
183 "columns": [
184 { "name": "id", "type": "string", "nullable": false },
185 { "name": "encryption_key_id", "type": "string", "nullable": true },
186 { "name": "note", "type": "bytes", "nullable": true,
187 "encrypted": true, "declaredType": "string" }
188 ],
189 "scopes": []
190 }]
191 });
192 let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
193 .expect("test client");
194 let mut config = crate::values::EncryptionConfig::default();
195 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
196 config
197 .key_id_columns
198 .insert("secrets".to_owned(), "encryption_key_id".to_owned());
199 client.set_encryption(config);
200 let commit_id = client
203 .patch(
204 "secrets",
205 "ghost",
206 Map::from_iter([("note".to_owned(), json!("x"))]),
207 None,
208 )
209 .expect("patch records without an author-time encrypt failure");
210 assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
211 assert!(client.drop_unencodable_outbox().unwrap());
213 assert!(client.pending_commit_ids().is_empty());
214 let rejections = client.rejections();
215 assert_eq!(rejections.len(), 1);
216 assert_eq!(rejections[0].code, "client.encrypt_failed");
217 assert_eq!(rejections[0].client_commit_id, commit_id);
218 assert_eq!(
219 client
220 .commit_outcome(&commit_id)
221 .unwrap()
222 .map(|outcome| outcome.status),
223 Some(CommitOutcomeStatus::Rejected)
224 );
225 }
226
227 #[test]
228 fn patch_with_a_scope_column_matches_the_stored_local_row() {
229 let mut client = client();
230 client.create_synced_tables().unwrap();
231 client
232 .conn
233 .execute(
234 "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
235 [],
236 )
237 .unwrap();
238
239 client
242 .patch(
243 "tasks",
244 "t1",
245 Map::from_iter([
246 ("project_id".to_owned(), json!("p1")),
247 ("id".to_owned(), json!("t1")),
248 ]),
249 None,
250 )
251 .expect("an equal scope column is a no-op");
252 let values = client.outbox[0].ops[0]
253 .values
254 .clone()
255 .expect("the patch records values");
256 assert_eq!(values.get("id"), Some(&json!("t1")));
257 assert!(!values.contains_key("project_id"));
258
259 let differing = client.patch(
261 "tasks",
262 "t1",
263 Map::from_iter([("project_id".to_owned(), json!("p2"))]),
264 None,
265 );
266 assert!(differing
267 .expect_err("a differing scope column is rejected")
268 .contains("patch cannot write scope column"));
269
270 let absent = client.patch(
273 "tasks",
274 "ghost",
275 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
276 None,
277 );
278 assert!(absent
279 .expect_err("an absent local row is rejected")
280 .contains("patch cannot write scope column"));
281 assert_eq!(client.pending_commit_ids().len(), 1);
282 }
283
284 #[test]
285 fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
286 let schema = json!({
287 "version": 1,
288 "tables": [
289 {
290 "name": "tenants",
291 "primaryKey": "tenant_id",
292 "columns": [
293 { "name": "tenant_id", "type": "string", "nullable": false },
294 { "name": "body", "type": "string", "nullable": false }
295 ],
296 "scopes": [{ "pattern": "tenant:{tenant_id}" }]
297 },
298 {
299 "name": "buckets",
300 "primaryKey": "id",
301 "columns": [
302 { "name": "id", "type": "string", "nullable": false },
303 { "name": "bucket", "type": "float", "nullable": false },
304 { "name": "body", "type": "string", "nullable": false }
305 ],
306 "scopes": [{ "pattern": "bucket:{bucket}" }]
307 }
308 ]
309 });
310 let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
311 .expect("test client");
312 client.create_synced_tables().unwrap();
313
314 client
318 .patch(
319 "tenants",
320 "t1",
321 Map::from_iter([
322 ("tenant_id".to_owned(), json!("t1")),
323 ("body".to_owned(), json!("created")),
324 ]),
325 None,
326 )
327 .expect("the primary-key scope column is proven equal by construction");
328
329 client
331 .conn
332 .execute(
333 "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
334 [],
335 )
336 .unwrap();
337 client
338 .patch(
339 "buckets",
340 "b1",
341 Map::from_iter([
342 ("bucket".to_owned(), json!(2)),
343 ("body".to_owned(), json!("coerced")),
344 ]),
345 None,
346 )
347 .expect("the stored REAL value coerces from a supplied integer");
348 let values = client.outbox[1].ops[0]
349 .values
350 .clone()
351 .expect("the patch records values");
352 assert!(!values.contains_key("bucket"));
353
354 let differing = client.patch(
356 "buckets",
357 "b1",
358 Map::from_iter([("bucket".to_owned(), json!(3))]),
359 None,
360 );
361 assert!(differing
362 .expect_err("a differing scope value is rejected")
363 .contains("patch cannot write scope column"));
364 }
365
366 #[cfg(feature = "e2ee")]
367 #[test]
368 fn stored_key_fallback_resolves_an_integer_primary_key() {
369 let schema = json!({
370 "version": 1,
371 "tables": [{
372 "name": "patients",
373 "primaryKey": "id",
374 "columns": [
375 { "name": "id", "type": "integer", "nullable": false },
376 { "name": "encryption_key_id", "type": "string", "nullable": true },
377 { "name": "note", "type": "bytes", "nullable": true,
378 "encrypted": true, "declaredType": "string" }
379 ],
380 "scopes": []
381 }]
382 });
383 let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
384 .expect("test client");
385 client.create_synced_tables().unwrap();
386 client
387 .conn
388 .execute(
389 "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
390 VALUES (5, 'k1', NULL, 1)",
391 [],
392 )
393 .unwrap();
394 let mut config = crate::values::EncryptionConfig::default();
395 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
396 config
397 .key_id_columns
398 .insert("patients".to_owned(), "encryption_key_id".to_owned());
399 client.set_encryption(config);
400 let table = client.schema.table("patients").unwrap();
401 let fallback = client
402 .stored_key_fallback(table, "5")
403 .expect("the stored integer key resolves the fallback");
404 assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
405 }
406
407 #[test]
408 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
409 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
410 {"name":"id","type":"string","nullable":false},
411 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
412 for state in ["absent", "cached", "pinned"] {
413 let path = std::env::temp_dir().join(format!(
414 "syncular-blob-stage-{}.sqlite",
415 uuid::Uuid::new_v4()
416 ));
417 let mut client = SyncClient::open_path(
418 "stage-test".into(),
419 &schema,
420 Default::default(),
421 path.to_str().unwrap(),
422 )
423 .unwrap();
424 let bytes = b"atomic stage";
425 if state != "absent" {
426 client
427 .upload_blob(bytes, Some("text/plain".into()), None)
428 .unwrap();
429 if state == "cached" {
430 client
431 .conn
432 .execute("DELETE FROM _syncular_blob_uploads", [])
433 .unwrap();
434 }
435 }
436 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
437 client
438 .conn
439 .execute_batch(
440 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
441 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
442 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
443 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
444 )
445 .unwrap();
446 let error = client
447 .upload_blob(bytes, Some("application/octet-stream".into()), None)
448 .unwrap_err();
449 assert!(error.contains("injected stage failure"), "{state}: {error}");
450 assert!(client.conn.is_autocommit());
451 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
452 drop(client);
453 let mut reopened = SyncClient::open_path(
454 "stage-test".into(),
455 &schema,
456 Default::default(),
457 path.to_str().unwrap(),
458 )
459 .unwrap();
460 let durable = reopened
461 .query("SELECT * FROM _syncular_blobs", &[])
462 .unwrap();
463 reopened
464 .conn
465 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
466 .unwrap();
467 let retried = reopened.upload_blob(bytes, None, None).unwrap();
468 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
469 assert_eq!(
470 reopened
471 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
472 .unwrap(),
473 vec![Map::from_iter([(
474 "blob_id".into(),
475 Value::from(blob_id_for(bytes)),
476 )])]
477 );
478 drop(reopened);
479 std::fs::remove_file(&path).unwrap();
480 assert_eq!(durable, bodies, "{state}: reopened body state");
481 assert_eq!(immediate, bodies, "{state}: immediate body state");
482 }
483 }
484
485 #[test]
486 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
487 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
488 {"name":"id","type":"string","nullable":false},
489 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
490 for fault in [
491 "missing",
492 "body-type",
493 "length",
494 "hash",
495 "pin-metadata",
496 "read",
497 "pin-delete",
498 ] {
499 let path = std::env::temp_dir().join(format!(
500 "syncular-blob-upload-{}.sqlite",
501 uuid::Uuid::new_v4()
502 ));
503 let mut client = SyncClient::open_path(
504 "upload-test".into(),
505 &schema,
506 Default::default(),
507 path.to_str().unwrap(),
508 )
509 .unwrap();
510 let bytes = b"durable upload";
511 let ref_value = client
512 .upload_blob(bytes, Some("text/plain".into()), None)
513 .unwrap();
514 let commit = client
515 .mutate(vec![Mutation::Upsert {
516 table: "attachments".into(),
517 values: Map::from_iter([
518 ("id".into(), Value::from("pending")),
519 (
520 "file".into(),
521 Value::from(format!(
522 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
523 blob_id_for(bytes),
524 bytes.len()
525 )),
526 ),
527 ]),
528 base_version: None,
529 }])
530 .unwrap();
531 client
532 .conn
533 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
534 .unwrap();
535 client.conn.execute_batch(match fault {
536 "missing" => "DELETE FROM _syncular_blobs",
537 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
538 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
539 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
540 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
541 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
542 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
543 }).unwrap();
544 let state = if fault == "read" {
545 Vec::new()
546 } else {
547 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
548 };
549 let mut transport = CountingRealtimeTransport::default();
550 let error = client.flush_blob_uploads(&mut transport).unwrap_err();
551 assert_eq!(
552 error.code,
553 if matches!(fault, "read" | "pin-delete") {
554 "client.failed"
555 } else {
556 "sync.local_corrupt"
557 },
558 "{fault}"
559 );
560 assert_eq!(
561 transport.blob_uploads.len(),
562 usize::from(fault == "pin-delete"),
563 "{fault}: corrupt bytes must never be uploaded"
564 );
565 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
566 if fault != "read" {
567 assert_eq!(
568 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
569 state
570 );
571 } else {
572 client
573 .conn
574 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
575 .unwrap();
576 }
577 drop(client);
578 let mut reopened = SyncClient::open_path(
579 "upload-test".into(),
580 &schema,
581 Default::default(),
582 path.to_str().unwrap(),
583 )
584 .unwrap();
585 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
586 if fault != "read" {
587 assert_eq!(
588 reopened
589 .query("SELECT * FROM _syncular_blobs", &[])
590 .unwrap(),
591 state
592 );
593 }
594 if fault == "pin-delete" {
595 reopened
596 .conn
597 .execute_batch("DROP TRIGGER fail_pin_delete")
598 .unwrap();
599 }
600 if fault == "pin-metadata" {
601 reopened
602 .conn
603 .execute(
604 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
605 [],
606 )
607 .unwrap();
608 }
609 reopened
610 .conn
611 .execute_batch(
612 "DELETE FROM _syncular_blobs;
613 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
614 )
615 .unwrap();
616 transport.blob_uploads.clear();
617 reopened.flush_blob_uploads(&mut transport).unwrap();
618 assert_eq!(
619 transport.blob_uploads,
620 [(blob_id_for(bytes), bytes.to_vec())]
621 );
622 assert_eq!(
623 reopened
624 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
625 .unwrap(),
626 Vec::<Map<String, Value>>::new()
627 );
628 assert_eq!(reopened.pending_commit_ids(), [commit]);
629 assert_eq!(
630 reopened
631 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
632 .unwrap()
633 .unwrap()
634 .bytes,
635 bytes
636 );
637 drop(reopened);
638 std::fs::remove_file(path).unwrap();
639 }
640 }
641
642 #[test]
643 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
644 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
645 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
646 "scopes":[{"pattern":"project:{project_id}"}]}]});
647 for realtime in [false, true] {
648 for fault in [
649 "none",
650 "row",
651 "revision",
652 "commit",
653 "cursor",
654 "error",
655 "missing-end",
656 ] {
657 if realtime && fault == "missing-end" {
658 continue;
659 }
660 let path = std::env::temp_dir()
661 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
662 let mut client = SyncClient::open_path(
663 "frame-test".into(),
664 &schema,
665 Default::default(),
666 path.to_str().unwrap(),
667 )
668 .unwrap();
669 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
670 client
671 .subscribe(
672 "tasks".into(),
673 "tasks".into(),
674 vec![("project_id".into(), vec!["p1".into()])],
675 None,
676 )
677 .unwrap();
678 client.subs[0].cursor = 0;
679 client.subs[0].synced_once = true;
680 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
681 client.persist_sub(&client.subs[0]).unwrap();
682 client.realtime_connected = true;
683 client.drain_change_batches();
684 let mut transport = CountingRealtimeTransport::default();
685 let (_, meta) = client.build_request(false);
686 let mut response = Message {
687 wire_version: WIRE_VERSION,
688 msg_kind: MsgKind::Response,
689 frames: vec![
690 Frame::RespHeader {
691 required_schema_version: None,
692 latest_schema_version: None,
693 log_epoch: Some("epoch-1".into()),
694 reset_required: Some(false),
695 },
696 Frame::SubStart {
697 id: "tasks".into(),
698 status: SubStatus::Active,
699 reason_code: "".into(),
700 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
701 bootstrap: false,
702 },
703 ],
704 };
705 for (index, id) in ["first", "second"].iter().enumerate() {
706 let values = Map::from_iter([
707 ("id".into(), Value::from(*id)),
708 ("project_id".into(), Value::from("p1")),
709 ]);
710 response.frames.push(Frame::Commit {
711 commit_seq: index as i64 + 1,
712 actor_id: "writer".into(),
713 created_at_ms: 1,
714 tables: vec!["tasks".into()],
715 changes: vec![ssp2::model::Change {
716 table_index: 0,
717 row_id: (*id).into(),
718 op: Op::Upsert,
719 row_version: Some(index as i64 + 1),
720 scopes: vec![("project_id".into(), "p1".into())],
721 row: Some(
722 encode_row_json(
723 client.schema.table("tasks").unwrap(),
724 id,
725 &values,
726 &client.encryption,
727 )
728 .unwrap(),
729 ),
730 }],
731 });
732 }
733 response.frames.push(Frame::SubEnd {
734 next_cursor: 2,
735 bootstrap_state: None,
736 });
737 let valid = response.clone();
738 match fault {
739 "row" => {
740 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
741 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
742 },
743 "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(),
744 "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(),
745 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
746 "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});},
747 "missing-end" => {response.frames.truncate(3);},
748 _ => {}
749 }
750 if realtime {
751 client.on_realtime_binary(&mut transport, &encode_message(&response));
752 } else {
753 assert_eq!(
754 matches!(
755 client.process_response(&mut transport, response, &meta),
756 SyncOutcome::Ok(_)
757 ),
758 fault == "none",
759 "{fault}"
760 );
761 }
762 assert!(
763 client.conn.is_autocommit(),
764 "no open transaction after {realtime}/{fault}"
765 );
766 let count = if matches!(fault, "none" | "cursor") {
767 2
768 } else {
769 1
770 };
771 let expected_revision = count + u64::from(realtime && fault != "none");
772 assert_eq!(
773 client.local_revision(),
774 expected_revision,
775 "{realtime}/{fault}"
776 );
777 let batches = client.drain_change_batches();
778 assert_eq!(
779 batches.len(),
780 expected_revision as usize,
781 "{realtime}/{fault}"
782 );
783 let row_batches: Vec<_> = batches
784 .iter()
785 .filter(|batch| !batch.tables.is_empty())
786 .collect();
787 assert_eq!(row_batches.len(), count as usize);
788 assert_eq!(
789 row_batches
790 .iter()
791 .map(|b| b.revision.clone())
792 .collect::<Vec<_>>(),
793 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
794 );
795 for table in ["tasks", "_syncular_base_tasks"] {
796 assert_eq!(
797 client
798 .conn
799 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
800 .get::<_, u64>(0))
801 .unwrap(),
802 count,
803 "{realtime}/{fault}/{table}"
804 );
805 }
806 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
807 assert_eq!(
808 transport
809 .messages
810 .iter()
811 .filter(|m| m.contains("\"ack\""))
812 .count(),
813 usize::from(fault == "none")
814 );
815 if matches!(fault, "revision" | "commit" | "cursor") {
816 client
817 .conn
818 .execute_batch("DROP TRIGGER fail_frame")
819 .unwrap();
820 }
821 drop(client);
822 let mut reopened = SyncClient::open_path(
823 "frame-test".into(),
824 &schema,
825 Default::default(),
826 path.to_str().unwrap(),
827 )
828 .unwrap();
829 assert_eq!(reopened.local_revision(), expected_revision);
830 assert_eq!(
831 reopened
832 .query("SELECT id FROM tasks ORDER BY id", &[])
833 .unwrap()
834 .len(),
835 count as usize
836 );
837 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
838 let (_, meta) = reopened.build_request(false);
839 assert!(matches!(
840 reopened.process_response(&mut transport, valid, &meta),
841 SyncOutcome::Ok(_)
842 ));
843 assert_eq!(
844 reopened
845 .query("SELECT id FROM tasks ORDER BY id", &[])
846 .unwrap()
847 .len(),
848 2
849 );
850 assert_eq!(reopened.subs[0].cursor, 2);
851 drop(reopened);
852 std::fs::remove_file(path).unwrap();
853 }
854 }
855 }
856
857 #[test]
858 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
859 for status in [SubStatus::Reset, SubStatus::Revoked] {
860 for fault in ["subscription", "revision"] {
861 let mut client = client();
862 client
863 .subscribe(
864 "tasks".into(),
865 "tasks".into(),
866 vec![("project_id".into(), vec!["p1".into()])],
867 None,
868 )
869 .unwrap();
870 client.subs[0].cursor = 41;
871 client.subs[0].synced_once = true;
872 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
873 client.persist_sub(&client.subs[0]).unwrap();
874 client
875 .mutate(vec![Mutation::Upsert {
876 table: "tasks".into(),
877 values: Map::from_iter([
878 ("id".into(), Value::from("pending")),
879 ("project_id".into(), Value::from("p1")),
880 ]),
881 base_version: None,
882 }])
883 .unwrap();
884 let ids = client.pending_commit_ids();
885 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
886 let revision = client.local_revision();
887 client.drain_change_batches();
888 let (_, meta) = client.build_request(false);
889 client.conn.execute_batch(if fault == "subscription" {
890 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
891 } else {
892 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
893 }).unwrap();
894 let mut transport = CountingRealtimeTransport::default();
895 let mut report = SyncReport::default();
896 if status == SubStatus::Reset {
898 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
899 }
900 let result = client.process_section(
901 &mut transport,
902 "tasks",
903 status,
904 "sync.scope_revoked",
905 vec![],
906 vec![],
907 Some((42, None)),
908 &meta,
909 &mut report,
910 );
911 assert!(result.is_err(), "{status:?}/{fault}");
912 assert!(client.conn.is_autocommit());
913 assert_eq!(client.subs[0].cursor, 41);
914 assert_eq!(client.subs[0].state, SubState::Active);
915 assert_eq!(client.pending_commit_ids(), ids);
916 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
917 assert!(client.rejections.is_empty());
918 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
919 assert_eq!(client.local_revision(), revision);
920 assert!(client.drain_change_batches().is_empty());
921 assert!(report.revoked.is_empty() && report.resets.is_empty());
922 }
923 }
924 }
925
926 #[test]
927 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
928 let mut client = client();
929 client
930 .subscribe(
931 "tasks".into(),
932 "tasks".into(),
933 vec![("project_id".into(), vec!["p1".into()])],
934 None,
935 )
936 .unwrap();
937 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
938 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
939 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
940 let row = |id: &str| ssp2::segment::SegmentRow {
941 server_version: 1,
942 values: vec![
943 Some(ColumnValue::String(id.into())),
944 Some(ColumnValue::String("p1".into())),
945 ],
946 };
947 let segment = RowsSegment {
948 table: "tasks".into(),
949 schema_version: 1,
950 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
951 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
952 };
953 assert!(client.apply_segment(0, &segment, true).is_err());
954 assert!(client.conn.is_autocommit());
955 assert_eq!(
956 client
957 .query("SELECT id FROM tasks ORDER BY id", &[])
958 .unwrap(),
959 vec![Map::from_iter([("id".into(), Value::from("first"))])]
960 );
961 assert_eq!(client.local_revision(), 1);
962 assert_eq!(client.drain_change_batches().len(), 1);
963 assert_eq!(client.subs[0].cursor, -1);
964 client
965 .conn
966 .execute_batch("DROP TRIGGER fail_block")
967 .unwrap();
968 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
969 assert_eq!(client.local_revision(), 3);
970 assert_eq!(client.drain_change_batches().len(), 2);
971 }
972
973 #[test]
974 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
975 assert_eq!(
976 client().diagnostics_storage().blob_cache_bytes_approx,
977 Some(0)
978 );
979 let client = SyncClient::new(
980 "diagnostic-storage".to_owned(),
981 &json!({
982 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
983 "columns": [{"name": "id", "type": "string", "nullable": false},
984 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
985 }),
986 ClientLimits {
987 blob_cache_max_bytes: Some(5),
988 ..Default::default()
989 },
990 )
991 .unwrap();
992 let empty = client.diagnostics_storage();
993 assert_eq!(empty.status, "healthy");
994 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
995 assert_eq!(empty.retained_outcome_entries, Some(0));
996 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
997 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
998 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
999 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1000 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1001 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1002 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1003 let written = client.diagnostics_storage();
1004 assert_eq!(written.status, "pressure");
1005 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1006 assert_eq!(written.retained_outcome_entries, Some(2));
1007 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1008 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1009 assert_eq!(
1010 written.pressure_reason_code.as_deref(),
1011 Some("client.blob_cache_over_limit")
1012 );
1013 client
1014 .conn
1015 .execute_batch(
1016 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1017 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1018 )
1019 .unwrap();
1020 let staged = client.diagnostics_storage();
1021 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1022 assert_eq!(staged.retained_outcome_entries, Some(0));
1023 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1024 assert_eq!(staged.status, "healthy");
1025 client.conn.execute_batch("ROLLBACK").unwrap();
1026 assert_eq!(client.diagnostics_storage(), written);
1027 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1028 let unreadable = client.diagnostics_storage();
1029 assert_eq!(unreadable.status, "unreadable");
1030 assert!(unreadable.database_bytes_approx.is_none());
1031 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1032 assert!(unreadable.retained_outcome_entries.is_none());
1033 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1034 assert!(unreadable.blob_cache_bytes_approx.is_none());
1035 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1036 let restored = client.diagnostics_storage();
1037 assert_eq!(restored.status, written.status);
1038 assert_eq!(
1039 restored.retained_outcome_bytes_approx,
1040 written.retained_outcome_bytes_approx
1041 );
1042 client.conn.set_prepared_statement_cache_capacity(0);
1043 assert_eq!(client.diagnostics_storage(), restored);
1044 }
1045
1046 #[test]
1047 fn background_retry_deadlines_back_off_and_reset() {
1048 let mut client = client();
1049 client.schedule_background_retry();
1050 assert!(matches!(
1051 client.drain_sync_intents().as_slice(),
1052 [SyncIntent::Background { delay_ms: 250 }]
1053 ));
1054 client.schedule_background_retry();
1055 assert!(matches!(
1056 client.drain_sync_intents().as_slice(),
1057 [SyncIntent::Background { delay_ms: 500 }]
1058 ));
1059 client.reset_background_retry();
1060 client.schedule_background_retry();
1061 assert!(matches!(
1062 client.drain_sync_intents().as_slice(),
1063 [SyncIntent::Background { delay_ms: 250 }]
1064 ));
1065 }
1066
1067 #[test]
1068 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1069 let mut client = client();
1070 client
1071 .subscribe(
1072 "epoch-tasks".to_owned(),
1073 "tasks".to_owned(),
1074 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1075 None,
1076 )
1077 .expect("subscribe");
1078 client.drain_sync_intents();
1079
1080 client
1081 .run_log_epoch_reset("epoch-2")
1082 .expect("reset partition log epoch");
1083
1084 assert!(matches!(
1085 client.drain_sync_intents().as_slice(),
1086 [SyncIntent::Interactive]
1087 ));
1088 }
1089
1090 #[derive(Default)]
1091 struct CountingRealtimeTransport {
1092 connects: usize,
1093 closes: usize,
1094 messages: Vec<String>,
1095 blob_uploads: Vec<(String, Vec<u8>)>,
1096 blob_download: Option<Vec<u8>>,
1097 }
1098
1099 impl Transport for CountingRealtimeTransport {
1100 fn blob_upload(
1101 &mut self,
1102 blob_id: &str,
1103 bytes: &[u8],
1104 _media_type: Option<&str>,
1105 ) -> Result<(), TransportError> {
1106 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1107 Ok(())
1108 }
1109
1110 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1111 self.blob_download
1112 .clone()
1113 .map(BlobDownload::Bytes)
1114 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1115 }
1116
1117 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1118 Err(TransportError::new("sync.transport_failed", "offline"))
1119 }
1120
1121 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1122 Err(TransportError::new("sync.transport_failed", "offline"))
1123 }
1124
1125 fn download_segment(
1126 &mut self,
1127 _request: &SegmentRequest,
1128 _on_progress: &mut dyn FnMut(u64),
1129 ) -> Result<Vec<u8>, TransportError> {
1130 Err(TransportError::new("sync.transport_failed", "offline"))
1131 }
1132
1133 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1134 self.connects += 1;
1135 Ok(())
1136 }
1137
1138 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1139 self.messages.push(text.to_owned());
1140 Ok(())
1141 }
1142
1143 fn realtime_close(&mut self) -> Result<(), TransportError> {
1144 self.closes += 1;
1145 Ok(())
1146 }
1147 }
1148
1149 #[test]
1150 fn blob_results_own_downloaded_bytes() {
1151 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1152 {"name":"id","type":"string","nullable":false},
1153 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1154 let bytes = b"owned typed result".to_vec();
1155 let blob_id = blob_id_for(&bytes);
1156 let mut transport = CountingRealtimeTransport {
1157 blob_download: Some(bytes.clone()),
1158 ..Default::default()
1159 };
1160 let result = {
1161 let mut client =
1162 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1163 .unwrap();
1164 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1165 assert_eq!(result.blob_id, blob_id);
1166 assert_eq!(result.byte_length, bytes.len() as i64);
1167 assert_eq!(result.bytes, bytes);
1168 assert_eq!(result.media_type, None);
1169 assert_eq!(
1170 client
1171 .conn
1172 .query_row(
1173 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1174 rusqlite::params![blob_id],
1175 |row| row.get::<_, i64>(0),
1176 )
1177 .unwrap(),
1178 0
1179 );
1180 result
1181 };
1182 assert_eq!(result.bytes, bytes);
1183
1184 let mut client =
1185 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1186 assert_eq!(
1187 client
1188 .fetch_blob_bytes(&mut transport, "not json")
1189 .unwrap_err(),
1190 (
1191 "client.failed".to_owned(),
1192 "blob ref is not JSON".to_owned()
1193 )
1194 );
1195 }
1196
1197 #[test]
1198 fn visible_blob_references_survive_cache_trimming() {
1199 let schema = json!({"version":1,"tables":[
1200 {"name":"attachments","primaryKey":"id","columns":[
1201 {"name":"id","type":"string","nullable":false},
1202 {"name":"body","type":"blob_ref","nullable":true},
1203 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1204 {"name":"avatars","primaryKey":"id","columns":[
1205 {"name":"id","type":"string","nullable":false},
1206 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1207 ]});
1208 let bytes = b"referenced above cap".to_vec();
1209 let blob_id = blob_id_for(&bytes);
1210 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1211 let mut client = SyncClient::new(
1212 "visible-blob-refs".to_owned(),
1213 &schema,
1214 ClientLimits {
1215 blob_cache_max_bytes: Some(1),
1216 ..Default::default()
1217 },
1218 )
1219 .unwrap();
1220 client.conn.execute(
1221 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1222 rusqlite::params![reference, reference, reference, reference],
1223 ).unwrap();
1224 client
1225 .conn
1226 .execute(
1227 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1228 rusqlite::params![reference],
1229 )
1230 .unwrap();
1231 client.conn.execute(
1232 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1233 [],
1234 ).unwrap();
1235 let mut transport = CountingRealtimeTransport {
1236 blob_download: Some(bytes.clone()),
1237 ..Default::default()
1238 };
1239
1240 assert_eq!(
1241 client
1242 .fetch_blob_bytes(&mut transport, &blob_id)
1243 .unwrap()
1244 .bytes,
1245 bytes
1246 );
1247 let rows = client
1248 .conn
1249 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1250 .unwrap()
1251 .query_map([], |row| row.get::<_, String>(0))
1252 .unwrap()
1253 .collect::<Result<Vec<_>, _>>()
1254 .unwrap();
1255 assert_eq!(rows, vec![blob_id]);
1256 }
1257
1258 #[test]
1259 fn realtime_connection_ownership_is_idempotent() {
1260 let mut client = client();
1261 let mut transport = CountingRealtimeTransport {
1262 connects: 0,
1263 closes: 0,
1264 ..Default::default()
1265 };
1266 client
1267 .connect_realtime(&mut transport)
1268 .expect("first connect");
1269 client
1270 .connect_realtime(&mut transport)
1271 .expect("idempotent connect");
1272 assert_eq!(transport.connects, 1);
1273 client.disconnect_realtime(&mut transport);
1274 client.disconnect_realtime(&mut transport);
1275 assert_eq!(transport.closes, 1);
1276 client
1277 .connect_realtime(&mut transport)
1278 .expect("deliberate reconnect");
1279 assert_eq!(transport.connects, 2);
1280 }
1281
1282 #[test]
1283 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1284 let mut client = client();
1285 client
1286 .subscribe(
1287 "repair-tasks".to_owned(),
1288 "tasks".to_owned(),
1289 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1290 None,
1291 )
1292 .expect("subscribe");
1293 {
1294 let sub = client
1295 .subs
1296 .iter_mut()
1297 .find(|sub| sub.id == "repair-tasks")
1298 .expect("subscription");
1299 sub.cursor = 42;
1300 sub.synced_once = true;
1301 let persisted = sub.clone();
1302 client.persist_sub(&persisted).unwrap();
1303 }
1304 for table in ["_syncular_base_tasks", "tasks"] {
1305 client
1306 .conn
1307 .execute(
1308 &format!(
1309 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1310 ),
1311 rusqlite::params!["server-row", "p1"],
1312 )
1313 .expect("seed server row");
1314 }
1315 let pending = client
1316 .mutate(vec![Mutation::Upsert {
1317 table: "tasks".to_owned(),
1318 values: Map::from_iter([
1319 ("id".to_owned(), Value::from("offline-row")),
1320 ("project_id".to_owned(), Value::from("p1")),
1321 ]),
1322 base_version: None,
1323 }])
1324 .expect("queue offline work");
1325 client.drain_change_batches();
1326 client.drain_sync_intents();
1327
1328 assert_eq!(
1329 client
1330 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1331 rebootstrap_id: "support-case-001".to_owned(),
1332 })
1333 .expect("rebootstrap"),
1334 LocalDataRebootstrapResult {
1335 already_applied: false,
1336 retained_commits: 1,
1337 reset_subscriptions: 1,
1338 }
1339 );
1340 let visible_ids = client
1341 .conn
1342 .prepare("SELECT id FROM tasks ORDER BY id")
1343 .expect("prepare visible ids")
1344 .query_map([], |row| row.get::<_, String>(0))
1345 .expect("query visible ids")
1346 .collect::<Result<Vec<_>, _>>()
1347 .expect("collect visible ids");
1348 assert_eq!(visible_ids, vec!["offline-row"]);
1349 assert_eq!(client.pending_commit_ids(), vec![pending]);
1350 assert_eq!(
1351 client
1352 .subscription_state("repair-tasks")
1353 .expect("subscription")
1354 .cursor,
1355 -1
1356 );
1357 assert!(client.upgrading());
1358 assert!(client.sync_needed());
1359 assert!(matches!(
1360 client.drain_sync_intents().as_slice(),
1361 [SyncIntent::Interactive]
1362 ));
1363 assert_eq!(client.drain_change_batches().len(), 1);
1364
1365 assert_eq!(
1366 client
1367 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1368 rebootstrap_id: "support-case-001".to_owned(),
1369 })
1370 .expect("idempotent retry"),
1371 LocalDataRebootstrapResult {
1372 already_applied: true,
1373 retained_commits: 1,
1374 reset_subscriptions: 1,
1375 }
1376 );
1377 assert!(client.drain_change_batches().is_empty());
1378 }
1379
1380 #[test]
1381 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1382 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1383 assert_eq!(
1384 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1385 (3, 4)
1386 );
1387 assert_eq!(
1388 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1389 (0, 0)
1390 );
1391 for malformed in [
1392 "",
1393 "{}",
1394 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1395 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1396 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1397 ] {
1398 assert_eq!(
1399 decode_local_rebootstrap_receipt(malformed)
1400 .expect_err("malformed receipt must fail"),
1401 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1402 );
1403 }
1404 }
1405
1406 #[test]
1407 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1408 let path = std::env::temp_dir().join(format!(
1409 "syncular-rebootstrap-receipt-{}.db",
1410 uuid::Uuid::new_v4()
1411 ));
1412 let schema = json!({
1413 "version": 1,
1414 "tables": [{
1415 "name": "tasks",
1416 "primaryKey": "id",
1417 "columns": [
1418 { "name": "id", "type": "string", "nullable": false },
1419 { "name": "project_id", "type": "string", "nullable": false }
1420 ],
1421 "scopes": [{ "pattern": "project:{project_id}" }]
1422 }]
1423 });
1424 let path_string = path.to_str().expect("UTF-8 temp path");
1425
1426 {
1427 let mut first = SyncClient::open_path(
1428 "repair-restart-client".to_owned(),
1429 &schema,
1430 ClientLimits::default(),
1431 path_string,
1432 )
1433 .expect("first open");
1434 first
1435 .subscribe(
1436 "repair-tasks".to_owned(),
1437 "tasks".to_owned(),
1438 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1439 None,
1440 )
1441 .expect("subscribe");
1442 first
1443 .mutate(vec![Mutation::Upsert {
1444 table: "tasks".to_owned(),
1445 values: Map::from_iter([
1446 ("id".to_owned(), Value::from("offline-row")),
1447 ("project_id".to_owned(), Value::from("p1")),
1448 ]),
1449 base_version: None,
1450 }])
1451 .expect("queue offline work");
1452 assert_eq!(
1453 first
1454 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1455 rebootstrap_id: "restart-receipt".to_owned(),
1456 })
1457 .expect("first rebootstrap"),
1458 LocalDataRebootstrapResult {
1459 already_applied: false,
1460 retained_commits: 1,
1461 reset_subscriptions: 1,
1462 }
1463 );
1464 }
1465
1466 let mut reopened = SyncClient::open_path(
1467 "repair-restart-client".to_owned(),
1468 &schema,
1469 ClientLimits::default(),
1470 path_string,
1471 )
1472 .expect("reopen");
1473 reopened.drain_change_batches();
1474 reopened.drain_sync_intents();
1475 assert_eq!(
1476 reopened
1477 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1478 rebootstrap_id: "restart-receipt".to_owned(),
1479 })
1480 .expect("receipt replay"),
1481 LocalDataRebootstrapResult {
1482 already_applied: true,
1483 retained_commits: 1,
1484 reset_subscriptions: 1,
1485 }
1486 );
1487 assert!(reopened.drain_change_batches().is_empty());
1488 assert!(reopened.drain_sync_intents().is_empty());
1489 drop(reopened);
1490 std::fs::remove_file(path).expect("remove temp database");
1491 }
1492
1493 #[test]
1494 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1495 let mut malformed = client();
1496 malformed
1497 .subscribe(
1498 "repair-tasks".to_owned(),
1499 "tasks".to_owned(),
1500 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1501 None,
1502 )
1503 .expect("subscribe");
1504 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1505 malformed.drain_change_batches();
1506 malformed.drain_sync_intents();
1507 let malformed_error = malformed
1508 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1509 rebootstrap_id: "malformed".to_owned(),
1510 })
1511 .expect_err("malformed receipt must fail closed");
1512 assert_eq!(
1513 malformed_error,
1514 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1515 );
1516 assert!(!malformed.upgrading());
1517 assert_eq!(
1518 malformed
1519 .subscription_state("repair-tasks")
1520 .expect("unchanged subscription")
1521 .cursor,
1522 -1
1523 );
1524 assert!(malformed.drain_change_batches().is_empty());
1525 assert!(malformed.drain_sync_intents().is_empty());
1526
1527 let mut unreadable = client();
1528 unreadable
1529 .conn
1530 .execute(
1531 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1532 rusqlite::params!["server-row", "p1"],
1533 )
1534 .expect("seed visible row");
1535 unreadable
1536 .conn
1537 .execute("DROP TABLE _syncular_meta", [])
1538 .expect("break marker storage");
1539 let unreadable_error = unreadable
1540 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1541 rebootstrap_id: "unreadable".to_owned(),
1542 })
1543 .expect_err("unreadable marker storage must fail closed");
1544 assert_eq!(
1545 unreadable_error,
1546 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1547 );
1548 let visible_rows = unreadable
1549 .conn
1550 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1551 .expect("visible projection remains");
1552 assert_eq!(visible_rows, 1);
1553 assert!(!unreadable.upgrading());
1554 assert!(unreadable.drain_change_batches().is_empty());
1555 assert!(unreadable.drain_sync_intents().is_empty());
1556 }
1557
1558 #[test]
1559 fn local_rebootstrap_cannot_bypass_schema_floor() {
1560 let mut client = client();
1561 client.set_schema_floor(Some(SchemaFloor {
1562 required_schema_version: Some(2),
1563 latest_schema_version: Some(2),
1564 }));
1565 let error = client
1566 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1567 rebootstrap_id: "blocked-floor".to_owned(),
1568 })
1569 .expect_err("schema floor must block repair");
1570 assert!(error.contains("cannot bypass an active schema-floor stop"));
1571 }
1572
1573 #[test]
1574 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1575 let mut client = client();
1576 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1577 const COMMIT_COUNT: usize = 32;
1578
1579 for index in 0..COMMIT_COUNT {
1580 client
1581 .mutate(vec![Mutation::Upsert {
1582 table: "tasks".to_owned(),
1583 values: Map::from_iter([
1584 ("id".to_owned(), Value::from(format!("task-{index}"))),
1585 ("project_id".to_owned(), Value::from("project-1")),
1586 ]),
1587 base_version: None,
1588 }])
1589 .expect("queue commit");
1590 }
1591
1592 let (_, request_meta) = client.build_request(false);
1593 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1594 let mut frames = vec![Frame::RespHeader {
1595 required_schema_version: None,
1596 latest_schema_version: None,
1597 log_epoch: Some("epoch-1".to_owned()),
1598 reset_required: Some(false),
1599 }];
1600 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1601 |(index, client_commit_id)| Frame::PushResult {
1602 client_commit_id: client_commit_id.clone(),
1603 status: PushStatus::Applied,
1604 commit_seq: Some(index as i64 + 1),
1605 results: vec![OpResult::Applied { op_index: 0 }],
1606 },
1607 ));
1608 let response = Message {
1609 wire_version: WIRE_VERSION,
1610 msg_kind: MsgKind::Response,
1611 frames,
1612 };
1613 let mut transport =
1614 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1615
1616 client.overlay_rebuild_count.set(0);
1617 client.outcome_prune_count.set(0);
1618 let outcome = client.process_response(&mut transport, response, &request_meta);
1619 assert!(matches!(outcome, SyncOutcome::Ok(_)));
1620 assert!(client.pending_commit_ids().is_empty());
1621 assert_eq!(
1622 client.overlay_rebuild_count.get(),
1623 1,
1624 "one response must reconcile its acknowledged commits with one overlay rebuild"
1625 );
1626 assert_eq!(
1627 client.outcome_prune_count.get(),
1628 1,
1629 "one response must enforce outcome retention once"
1630 );
1631 }
1632
1633 #[test]
1634 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1635 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1636 "columns": [
1637 {"name":"id", "type":"string", "nullable":false},
1638 {"name":"project_id", "type":"string", "nullable":false},
1639 {"name":"title", "type":"string", "nullable":false}
1640 ], "scopes": [{"pattern":"project:{project_id}"}],
1641 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1642 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1643 }] });
1644 let mut incremental =
1645 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1646 let mut reference =
1647 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1648 let tables = vec!["tasks".to_owned()];
1649 for index in 0..32 {
1650 let id = format!("task-{}", index % 8);
1651 let values = Map::from_iter([
1652 ("id".to_owned(), Value::from(id.clone())),
1653 (
1654 "project_id".to_owned(),
1655 Value::from(format!("p{}", index % 2)),
1656 ),
1657 ("title".to_owned(), Value::from(format!("needle{index}"))),
1658 ]);
1659 let payload = encode_row_json(
1660 incremental.schema.table("tasks").unwrap(),
1661 &id,
1662 &values,
1663 &incremental.encryption,
1664 )
1665 .unwrap();
1666 let change = ssp2::model::Change {
1667 table_index: 0,
1668 row_id: id,
1669 op: if index % 5 == 0 {
1670 Op::Delete
1671 } else {
1672 Op::Upsert
1673 },
1674 row_version: Some(index + 1),
1675 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1676 row: Some(payload),
1677 };
1678 reference.overlay_dirty.set(true);
1679 for instance in [&mut incremental, &mut reference] {
1680 instance.begin_observation("mirror_test").unwrap();
1681 let mut batch = ChangeAccumulator::default();
1682 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1683 instance
1684 .apply_commit_changes(&tables, std::slice::from_ref(&change))
1685 .unwrap();
1686 instance.rebuild_overlay_if_dirty();
1687 instance.finish_observation("mirror_test", batch).unwrap();
1688 }
1689 for sql in [
1690 "SELECT * FROM tasks ORDER BY id",
1691 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1692 ] {
1693 assert_eq!(
1694 incremental.query(sql, &[]).unwrap(),
1695 reference.query(sql, &[]).unwrap()
1696 );
1697 }
1698 assert_eq!(incremental.local_revision(), reference.local_revision());
1699 assert_eq!(
1700 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1701 serde_json::to_value(reference.drain_change_batches()).unwrap()
1702 );
1703 }
1704 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1705 assert_eq!(reference.overlay_rebuild_count.get(), 32);
1706 }
1707
1708 #[test]
1709 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1710 let cases = [
1711 (
1712 "string",
1713 vec![
1714 json!("1"),
1715 json!("01"),
1716 json!(""),
1717 json!("nul\u{0}id"),
1718 json!("é"),
1719 ],
1720 ),
1721 (
1722 "integer",
1723 vec![
1724 json!(i64::MIN),
1725 json!(-1),
1726 json!(0),
1727 json!(1),
1728 json!(i64::MAX),
1729 ],
1730 ),
1731 ("boolean", vec![json!(false), json!(true)]),
1732 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
1733 ];
1734 for (kind, ids) in cases {
1735 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1736 "columns":[{"name":"id","type":kind,"nullable":false},
1737 {"name":"project_id","type":"string","nullable":false}],
1738 "scopes":[{"pattern":"project:{project_id}"}]}]});
1739 let mut client =
1740 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
1741 for (index, id) in ids.iter().enumerate() {
1742 let table = client.schema.table("tasks").unwrap();
1743 client
1744 .write_base_row(
1745 "tasks",
1746 &vec![
1747 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
1748 Some(ColumnValue::String(format!("p{index}"))),
1749 ],
1750 1,
1751 )
1752 .unwrap();
1753 }
1754 client.rebuild_overlay_if_dirty();
1755 for base in [true, false] {
1756 let full = if base {
1757 base_table("tasks")
1758 } else {
1759 visible_table("tasks")
1760 };
1761 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
1762 for row_id in [
1763 "1",
1764 "01",
1765 "1.0",
1766 "1.0000000000000002",
1767 "+1",
1768 "1e0",
1769 " 1",
1770 "-1",
1771 "0",
1772 "9223372036854775807",
1773 "-9223372036854775808",
1774 "true",
1775 "false",
1776 "",
1777 "nul\u{0}id",
1778 "é",
1779 "null",
1780 "{\"a\":1}",
1781 ] {
1782 let old_sql =
1783 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
1784 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
1785 let old = client
1786 .conn
1787 .prepare(&old_sql)
1788 .unwrap()
1789 .query_map([row_id], |row| row.get::<_, String>(0))
1790 .unwrap()
1791 .collect::<Result<Vec<_>, _>>()
1792 .unwrap();
1793 let new = client
1794 .conn
1795 .prepare_cached(&new_sql)
1796 .unwrap()
1797 .query_map([row_id], |row| row.get::<_, String>(0))
1798 .unwrap()
1799 .collect::<Result<Vec<_>, _>>()
1800 .unwrap();
1801 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
1802 let mut batch = ChangeAccumulator::default();
1803 assert_eq!(
1804 client.record_row_scopes(&mut batch, "tasks", row_id, base),
1805 !old.is_empty()
1806 );
1807 let expected = old.first().map(|scope| {
1808 BTreeMap::from([(
1809 "tasks".to_owned(),
1810 Some(BTreeSet::from([format!("project:{scope}")])),
1811 )])
1812 });
1813 assert_eq!(batch.tables, expected.unwrap_or_default());
1814 client
1815 .conn
1816 .execute_batch("SAVEPOINT lookup_delete")
1817 .unwrap();
1818 if base {
1819 client.delete_base_row("tasks", row_id).unwrap();
1820 } else {
1821 client.apply_outbox_ops(&[OutboxOp {
1822 upsert: false,
1823 table: "tasks".into(),
1824 row_id: row_id.into(),
1825 base_version: None,
1826 values: None,
1827 }]);
1828 }
1829 let remaining: i64 = client
1830 .conn
1831 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
1832 row.get(0)
1833 })
1834 .unwrap();
1835 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
1836 client
1837 .conn
1838 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
1839 .unwrap();
1840 client.overlay_dirty.set(false);
1841 }
1842 for (predicate, expected) in [
1843 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
1844 (predicate, "SEARCH"),
1845 ] {
1846 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
1847 let details = client
1848 .conn
1849 .prepare(&sql)
1850 .unwrap()
1851 .query_map(["1"], |row| row.get::<_, String>(3))
1852 .unwrap()
1853 .collect::<Result<Vec<_>, _>>()
1854 .unwrap();
1855 assert!(
1856 details.iter().any(|detail| detail.contains(expected)),
1857 "{kind}: {details:?}"
1858 );
1859 }
1860 }
1861 }
1862 }
1863
1864 #[test]
1865 fn primary_keys_reject_every_ineligible_column_type() {
1866 for ty in ["float", "bytes", "crdt", "blob_ref"] {
1870 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1871 "columns":[{"name":"id","type":ty,"nullable":false},
1872 {"name":"project_id","type":"string","nullable":false}],
1873 "scopes":[{"pattern":"project:{project_id}"}]}]});
1874 let error =
1875 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
1876 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
1877 Err(error) => error,
1878 };
1879 assert!(
1880 error.contains("has an ineligible column type (§2.4)"),
1881 "{ty}: {error}"
1882 );
1883 }
1884 for ty in ["string", "integer", "boolean", "json"] {
1885 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1886 "columns":[{"name":"id","type":ty,"nullable":false},
1887 {"name":"project_id","type":"string","nullable":false}],
1888 "scopes":[{"pattern":"project:{project_id}"}]}]});
1889 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
1890 .unwrap_or_else(|error| panic!("{ty}: {error}"));
1891 }
1892 }
1893
1894 #[test]
1895 fn pending_row_reconciliation_matches_full_fifo_replay() {
1896 for unique in [false, true] {
1897 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
1898 "name": name, "primaryKey": "id", "columns": [
1899 {"name":"id", "type":"string", "nullable":false},
1900 {"name":"project_id", "type":"string", "nullable":false},
1901 {"name":"title", "type":"string", "nullable":false}],
1902 "scopes": [{"pattern":"project:{project_id}"}],
1903 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
1904 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
1905 })))});
1906 let mut incremental =
1907 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1908 let mut reference =
1909 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1910 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
1911 for instance in [&mut incremental, &mut reference] {
1912 for table in &tables {
1913 for index in 0..12 {
1914 instance
1915 .write_base_row(
1916 table,
1917 &vec![
1918 Some(ColumnValue::String(format!("row{index}"))),
1919 Some(ColumnValue::String("p0".into())),
1920 Some(ColumnValue::String(format!("base{index}"))),
1921 ],
1922 1,
1923 )
1924 .unwrap();
1925 }
1926 }
1927 instance.rebuild_overlay_if_dirty();
1928 }
1929 for index in 0..80 {
1930 let mutations = (0..3)
1931 .map(|offset| {
1932 let table = tables[(index + offset) % 2].clone();
1933 let id = format!("row{}", (index + offset) % 16);
1934 if (index + offset) % 7 == 0 {
1935 Mutation::Delete {
1936 table,
1937 row_id: id,
1938 base_version: None,
1939 }
1940 } else {
1941 Mutation::Upsert {
1942 table,
1943 values: Map::from_iter([
1944 ("id".into(), Value::from(id)),
1945 ("project_id".into(), Value::from(format!("p{}", index % 3))),
1946 (
1947 "title".into(),
1948 Value::from(format!("needle{}", (index + offset) % 5)),
1949 ),
1950 ]),
1951 base_version: None,
1952 }
1953 }
1954 })
1955 .collect::<Vec<_>>();
1956 for instance in [&mut incremental, &mut reference] {
1957 instance.mutate(mutations.clone()).unwrap();
1958 }
1959 let changes = (0..3)
1962 .map(|offset| {
1963 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
1964 let id = format!("row{}", (index + offset) % 16);
1965 let values = Map::from_iter([
1966 ("id".into(), Value::from(id.clone())),
1967 (
1968 "project_id".into(),
1969 Value::from(format!("p{}", (index + 1) % 3)),
1970 ),
1971 (
1972 "title".into(),
1973 Value::from(format!("remote{index}_{offset}")),
1974 ),
1975 ]);
1976 ssp2::model::Change {
1977 table_index: table_index as u16,
1978 row_id: id.clone(),
1979 op: if (index + offset) % 5 == 0 {
1980 Op::Delete
1981 } else {
1982 Op::Upsert
1983 },
1984 row_version: Some(index as i64 + 2),
1985 scopes: vec![],
1986 row: Some(
1987 encode_row_json(
1988 incremental.schema.table(&tables[table_index]).unwrap(),
1989 &id,
1990 &values,
1991 &incremental.encryption,
1992 )
1993 .unwrap(),
1994 ),
1995 }
1996 })
1997 .collect::<Vec<_>>();
1998 reference.overlay_dirty.set(true);
1999 incremental.apply_commit_frame(&tables, &changes).unwrap();
2000 reference.apply_commit_frame(&tables, &changes).unwrap();
2001 for table in &tables {
2002 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2003 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2004 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2005 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2006 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2007 }
2008 }
2009 assert_eq!(incremental.local_revision(), reference.local_revision());
2010 assert_eq!(
2011 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2012 serde_json::to_value(reference.drain_change_batches()).unwrap()
2013 );
2014 }
2015 assert_eq!(
2016 incremental.overlay_rebuild_count.get(),
2017 if unique { 81 } else { 21 }
2018 );
2019 assert_eq!(reference.overlay_rebuild_count.get(), 81);
2020 }
2021 }
2022
2023 #[test]
2024 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2025 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2026 "columns":[{"name":"id", "type":"string", "nullable":false},
2027 {"name":"title", "type":"string", "nullable":false}],
2028 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2029 let mut client =
2030 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2031 for (id, title) in [("a", "original"), ("b", "occupied")] {
2032 client
2033 .write_base_row(
2034 "tasks",
2035 &vec![
2036 Some(ColumnValue::String(id.into())),
2037 Some(ColumnValue::String(title.into())),
2038 ],
2039 1,
2040 )
2041 .unwrap();
2042 }
2043 client.rebuild_overlay_if_dirty();
2044 let pending = client
2045 .mutate(vec![Mutation::Upsert {
2046 table: "tasks".into(),
2047 values: Map::from_iter([
2048 ("id".into(), Value::from("a")),
2049 ("title".into(), Value::from("occupied")),
2050 ]),
2051 base_version: None,
2052 }])
2053 .unwrap();
2054 assert_eq!(
2055 client
2056 .conn
2057 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2058 .get::<_, String>(
2059 0
2060 ))
2061 .unwrap(),
2062 "original"
2063 );
2064 for (title, expected) in [("available", "occupied"), ("occupied", "original")] {
2065 let change = ssp2::model::Change {
2066 table_index: 0,
2067 row_id: "b".into(),
2068 op: Op::Upsert,
2069 row_version: Some(2),
2070 scopes: vec![],
2071 row: Some(
2072 encode_row_json(
2073 client.schema.table("tasks").unwrap(),
2074 "b",
2075 &Map::from_iter([
2076 ("id".into(), Value::from("b")),
2077 ("title".into(), Value::from(title)),
2078 ]),
2079 &client.encryption,
2080 )
2081 .unwrap(),
2082 ),
2083 };
2084 client
2085 .apply_commit_frame(&["tasks".into()], &[change])
2086 .unwrap();
2087 assert_eq!(
2088 client
2089 .conn
2090 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2091 .get::<_, String>(
2092 0
2093 ))
2094 .unwrap(),
2095 expected
2096 );
2097 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2098 }
2099 assert_eq!(client.overlay_rebuild_count.get(), 3);
2100 }
2101
2102 #[test]
2103 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2104 for failure in ["visible", "revision", "commit"] {
2105 let mut client = client();
2106 let id = client
2107 .mutate(vec![Mutation::Upsert {
2108 table: "tasks".into(),
2109 values: Map::from_iter([
2110 ("id".into(), Value::from("t1")),
2111 ("project_id".into(), Value::from("pending")),
2112 ]),
2113 base_version: None,
2114 }])
2115 .unwrap();
2116 client.drain_change_batches();
2117 let revision = client.local_revision();
2118 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2119 let trigger = match failure {
2120 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2121 "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"),
2122 _ => "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(),
2123 };
2124 client.conn.execute_batch(&trigger).unwrap();
2125 let change = ssp2::model::Change {
2126 table_index: 0,
2127 row_id: "t1".into(),
2128 op: Op::Upsert,
2129 row_version: Some(2),
2130 scopes: vec![],
2131 row: Some(
2132 encode_row_json(
2133 client.schema.table("tasks").unwrap(),
2134 "t1",
2135 &Map::from_iter([
2136 ("id".into(), Value::from("t1")),
2137 ("project_id".into(), Value::from("remote")),
2138 ]),
2139 &client.encryption,
2140 )
2141 .unwrap(),
2142 ),
2143 };
2144 assert!(client
2145 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
2146 .is_err());
2147 assert!(client.conn.is_autocommit());
2148 assert!(!client.overlay_dirty.get());
2149 assert_eq!(client.local_revision(), revision);
2150 assert!(client.drain_change_batches().is_empty());
2151 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2152 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2153 assert!(client
2154 .query("SELECT * FROM _syncular_base_tasks", &[])
2155 .unwrap()
2156 .is_empty());
2157 client
2158 .conn
2159 .execute_batch("DROP TRIGGER fail_pending")
2160 .unwrap();
2161 client
2162 .apply_commit_frame(&["tasks".into()], &[change])
2163 .unwrap();
2164 assert_eq!(client.local_revision(), revision + 1);
2165 assert_eq!(client.pending_commit_ids(), vec![id]);
2166 let mut expected = before[0].clone();
2169 expected.insert("_syncular_version".to_owned(), Value::from(2));
2170 assert_eq!(
2171 client.query("SELECT * FROM tasks", &[]).unwrap(),
2172 vec![expected]
2173 );
2174 assert_eq!(
2175 client
2176 .conn
2177 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2178 .get::<_, String>(0))
2179 .unwrap(),
2180 "remote"
2181 );
2182 }
2183 }
2184
2185 #[test]
2186 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2187 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2188 "columns": [
2189 {"name":"id", "type":"string", "nullable":false},
2190 {"name":"project_id", "type":"string", "nullable":false},
2191 {"name":"title", "type":"string", "nullable":false}],
2192 "scopes": [{"pattern":"project:{project_id}"}],
2193 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2194 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2195 let mut incremental =
2196 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2197 let mut reference =
2198 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2199 for instance in [&mut incremental, &mut reference] {
2200 for index in 0..16 {
2201 instance
2202 .write_base_row(
2203 "tasks",
2204 &vec![
2205 Some(ColumnValue::String(format!("task{index}"))),
2206 Some(ColumnValue::String("p0".to_owned())),
2207 Some(ColumnValue::String(format!("base{index}"))),
2208 ],
2209 1,
2210 )
2211 .unwrap();
2212 }
2213 instance.rebuild_overlay_if_dirty();
2214 instance.overlay_rebuild_count.set(0);
2215 }
2216 for index in 0..100 {
2217 let mutations = (0..1 + index % 4)
2218 .map(|offset| {
2219 let id = format!("task{}", (index + offset) % 24);
2220 if (index + offset) % 7 == 0 {
2221 Mutation::Delete {
2222 table: "tasks".to_owned(),
2223 row_id: id,
2224 base_version: None,
2225 }
2226 } else {
2227 Mutation::Upsert {
2228 table: "tasks".to_owned(),
2229 values: Map::from_iter([
2230 ("id".to_owned(), Value::from(id)),
2231 (
2232 "project_id".to_owned(),
2233 Value::from(format!("p{}", index % 2)),
2234 ),
2235 (
2236 "title".to_owned(),
2237 Value::from(format!("needle{}", (index + offset) % 5)),
2238 ),
2239 ]),
2240 base_version: None,
2241 }
2242 }
2243 })
2244 .collect::<Vec<_>>();
2245 reference.overlay_dirty.set(true);
2246 incremental.mutate(mutations.clone()).unwrap();
2247 reference.mutate(mutations).unwrap();
2248 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2249 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2250 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2251 }
2252 assert_eq!(incremental.local_revision(), reference.local_revision());
2253 assert_eq!(
2254 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2255 serde_json::to_value(reference.drain_change_batches()).unwrap()
2256 );
2257 }
2258 assert_eq!(incremental.outbox.len(), 100);
2259 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2260 assert_eq!(reference.overlay_rebuild_count.get(), 100);
2261 }
2262
2263 #[test]
2264 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2265 for fail_at in ["outbox", "revision"] {
2266 let mut client = client();
2267 let values = Map::from_iter([
2268 ("id".to_owned(), Value::from("t1")),
2269 ("project_id".to_owned(), Value::from("p1")),
2270 ]);
2271 let mutation = Mutation::Upsert {
2272 table: "tasks".to_owned(),
2273 values,
2274 base_version: None,
2275 };
2276 let trigger = if fail_at == "outbox" {
2277 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2278 } else {
2279 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2280 };
2281 client.conn.execute_batch(&trigger).unwrap();
2282 let revision = client.local_revision();
2283 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2284 assert_eq!(client.local_revision(), revision);
2285 assert!(client.outbox.is_empty());
2286 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2287 assert_eq!(
2288 client
2289 .conn
2290 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2291 .get::<_, i64>(0))
2292 .unwrap(),
2293 0
2294 );
2295 assert!(client.drain_change_batches().is_empty());
2296 assert!(!client.overlay_dirty.get());
2297 client
2298 .conn
2299 .execute_batch("DROP TRIGGER fail_append")
2300 .unwrap();
2301 client.mutate(vec![mutation]).unwrap();
2302 assert_eq!(client.outbox.len(), 1);
2303 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2304 }
2305 }
2306
2307 #[test]
2308 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2309 let mut client = client();
2310 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2311 let values = Map::from_iter([
2312 ("id".to_owned(), Value::from("t1")),
2313 ("project_id".to_owned(), Value::from("p1")),
2314 ]);
2315 let payload = encode_row_json(
2316 client.schema.table("tasks").unwrap(),
2317 "t1",
2318 &values,
2319 &client.encryption,
2320 )
2321 .unwrap();
2322 let first = ssp2::model::Change {
2323 table_index: 0,
2324 row_id: "t1".to_owned(),
2325 op: Op::Upsert,
2326 row_version: Some(1),
2327 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2328 row: Some(payload),
2329 };
2330 let mut invalid = first.clone();
2331 invalid.table_index = 1;
2332 let revision = client.local_revision();
2333 client.begin_observation("failed_mirror").unwrap();
2334 assert!(client
2335 .apply_commit_changes(&tables, &[first, invalid])
2336 .is_err());
2337 client.rollback_observation("failed_mirror");
2338 client.rebuild_overlay_if_dirty();
2339 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2340 assert_eq!(
2341 client
2342 .conn
2343 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2344 .get::<_, i64>(
2345 0
2346 ))
2347 .unwrap(),
2348 0
2349 );
2350 assert_eq!(client.local_revision(), revision);
2351 assert!(client.drain_change_batches().is_empty());
2352 }
2353
2354 #[test]
2355 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2356 for failure in ["journal", "revision", "commit", "later-frame"] {
2357 let mut client = client();
2358 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2359 for id in ["first", "second"] {
2360 client
2361 .mutate(vec![Mutation::Upsert {
2362 table: "tasks".to_owned(),
2363 values: Map::from_iter([
2364 ("id".to_owned(), Value::from(id)),
2365 ("project_id".to_owned(), Value::from("p1")),
2366 ]),
2367 base_version: None,
2368 }])
2369 .unwrap();
2370 }
2371 let (_, meta) = client.build_request(false);
2372 client
2374 .mutate(vec![Mutation::Upsert {
2375 table: "tasks".to_owned(),
2376 values: Map::from_iter([
2377 ("id".to_owned(), Value::from("later")),
2378 ("project_id".to_owned(), Value::from("p1")),
2379 ]),
2380 base_version: None,
2381 }])
2382 .unwrap();
2383 let ids = client.pending_commit_ids();
2384 let rows = client
2385 .query("SELECT * FROM tasks ORDER BY id", &[])
2386 .unwrap();
2387 let revision = client.local_revision();
2388 client.drain_change_batches();
2389 if failure != "later-frame" {
2390 client.conn.execute_batch(match failure {
2391 "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",
2392 "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",
2393 _ => "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",
2394 }).unwrap();
2395 }
2396 let mut response = Message {
2397 wire_version: WIRE_VERSION,
2398 msg_kind: MsgKind::Response,
2399 frames: vec![Frame::RespHeader {
2400 required_schema_version: None,
2401 latest_schema_version: None,
2402 log_epoch: Some("epoch-1".to_owned()),
2403 reset_required: Some(false),
2404 }],
2405 };
2406 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2407 response.frames.push(Frame::PushResult {
2408 client_commit_id: id.to_owned(),
2409 status: PushStatus::Applied,
2410 commit_seq: Some(1),
2411 results: vec![OpResult::Applied { op_index: 0 }],
2412 });
2413 }
2414 if failure == "later-frame" {
2415 response.frames.push(Frame::Error {
2416 code: "sync.invalid_request".to_owned(),
2417 message: "later error".to_owned(),
2418 category: "protocol".to_owned(),
2419 retryable: false,
2420 recommended_action: "retry".to_owned(),
2421 details: None,
2422 });
2423 }
2424 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2425 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2426 assert!(
2427 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2428 );
2429 if failure == "later-frame" {
2430 assert_eq!(client.pending_commit_ids(), ids[2..]);
2431 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2432 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2433 let batches = client.drain_change_batches();
2434 assert_eq!(batches.len(), 1);
2435 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2436 assert_eq!(client.local_revision(), revision + 1);
2437 response.frames.pop();
2438 } else {
2439 assert_eq!(client.pending_commit_ids(), ids);
2440 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2441 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2442 assert_eq!(client.local_revision(), revision);
2443 assert!(client.drain_change_batches().is_empty());
2444 assert_eq!(
2445 client
2446 .query("SELECT * FROM tasks ORDER BY id", &[])
2447 .unwrap(),
2448 rows
2449 );
2450 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2451 }
2452 for frame in &mut response.frames {
2453 if let Frame::PushResult { status, .. } = frame {
2454 *status = PushStatus::Cached;
2455 }
2456 }
2457 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2458 else {
2459 panic!("retry failed");
2460 };
2461 assert_eq!(
2462 report.applied,
2463 if failure == "later-frame" {
2464 vec![]
2465 } else {
2466 ids[..2].to_vec()
2467 }
2468 );
2469 assert_eq!(client.pending_commit_ids(), ids[2..]);
2470 let batches = client.drain_change_batches();
2471 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2472 if let Some(batch) = batches.first() {
2473 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2474 }
2475 assert_eq!(client.local_revision(), revision + 1);
2476 }
2477 }
2478
2479 #[test]
2480 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2481 for failure in ["revision", "journal", "commit"] {
2482 for status in ["applied", "cached", "rejected", "conflict"] {
2483 let mut client = client();
2484 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2485 for id in ["first", "later"] {
2486 client
2487 .mutate(vec![Mutation::Upsert {
2488 table: "tasks".to_owned(),
2489 values: Map::from_iter([
2490 ("id".to_owned(), Value::from(id)),
2491 ("project_id".to_owned(), Value::from("p1")),
2492 ]),
2493 base_version: None,
2494 }])
2495 .unwrap();
2496 }
2497 let (_, meta) = client.build_request(false);
2498 let ids = client.pending_commit_ids();
2499 let rows = client
2500 .query("SELECT * FROM tasks ORDER BY id", &[])
2501 .unwrap();
2502 let revision = client.local_revision();
2503 client.drain_change_batches();
2504 let (push_status, results) = match status {
2505 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2506 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2507 "conflict" => (
2508 PushStatus::Rejected,
2509 vec![OpResult::Conflict {
2510 op_index: 0,
2511 code: "sync.version_conflict".to_owned(),
2512 message: "conflict".to_owned(),
2513 server_version: 1,
2514 server_row: encode_row_json(
2515 client.schema.table("tasks").unwrap(),
2516 "first",
2517 &Map::from_iter([
2518 ("id".to_owned(), Value::from("first")),
2519 ("project_id".to_owned(), Value::from("p1")),
2520 ]),
2521 &client.encryption,
2522 )
2523 .unwrap(),
2524 conflict_columns: Vec::new(),
2525 }],
2526 ),
2527 _ => (
2528 PushStatus::Rejected,
2529 vec![OpResult::Error {
2530 op_index: 0,
2531 code: "sync.validation_failed".to_owned(),
2532 message: "rejected".to_owned(),
2533 retryable: false,
2534 }],
2535 ),
2536 };
2537 let response = Message {
2538 wire_version: WIRE_VERSION,
2539 msg_kind: MsgKind::Response,
2540 frames: vec![
2541 Frame::RespHeader {
2542 required_schema_version: None,
2543 latest_schema_version: None,
2544 log_epoch: Some("epoch-1".to_owned()),
2545 reset_required: Some(false),
2546 },
2547 Frame::PushResult {
2548 client_commit_id: ids[0].clone(),
2549 status: push_status,
2550 commit_seq: Some(1),
2551 results,
2552 },
2553 ],
2554 };
2555 client.conn.execute_batch(match failure {
2556 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2557 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2558 _ => "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",
2559 }).unwrap();
2560 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2561 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2562 assert_eq!(
2563 client.pending_commit_ids(),
2564 ids,
2565 "{failure}/{status}: pending identity"
2566 );
2567 assert_eq!(
2568 client.local_revision(),
2569 revision,
2570 "{failure}/{status}: revision"
2571 );
2572 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2573 assert!(client.conflicts().is_empty());
2574 assert!(client.rejections().is_empty());
2575 assert!(client.drain_change_batches().is_empty());
2576 assert_eq!(
2577 client
2578 .query("SELECT * FROM tasks ORDER BY id", &[])
2579 .unwrap(),
2580 rows
2581 );
2582 assert!(
2583 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2584 "{failure}/{status}: failed round"
2585 );
2586 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2587 assert!(matches!(
2588 client.process_response(&mut transport, response, &meta),
2589 SyncOutcome::Ok(_)
2590 ));
2591 assert_eq!(client.pending_commit_ids(), ids[1..]);
2592 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2593 assert_eq!(client.drain_change_batches().len(), 1);
2594 }
2595 }
2596 }
2597
2598 #[test]
2599 fn mixed_push_results_reconcile_and_prune_once_per_response() {
2600 let mut client = client();
2601 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2602 for index in 0..4 {
2603 client
2604 .mutate(vec![Mutation::Upsert {
2605 table: "tasks".to_owned(),
2606 values: Map::from_iter([
2607 ("id".to_owned(), Value::from(format!("task-{index}"))),
2608 ("project_id".to_owned(), Value::from("project-1")),
2609 ]),
2610 base_version: None,
2611 }])
2612 .expect("queue commit");
2613 }
2614
2615 let (_, request_meta) = client.build_request(false);
2616 let ids = &request_meta.pushed_ids;
2617 assert_eq!(ids.len(), 4);
2618 let response = Message {
2619 wire_version: WIRE_VERSION,
2620 msg_kind: MsgKind::Response,
2621 frames: vec![
2622 Frame::RespHeader {
2623 required_schema_version: None,
2624 latest_schema_version: None,
2625 log_epoch: Some("epoch-1".to_owned()),
2626 reset_required: Some(false),
2627 },
2628 Frame::PushResult {
2629 client_commit_id: ids[0].clone(),
2630 status: PushStatus::Applied,
2631 commit_seq: Some(1),
2632 results: vec![OpResult::Applied { op_index: 0 }],
2633 },
2634 Frame::PushResult {
2635 client_commit_id: ids[1].clone(),
2636 status: PushStatus::Cached,
2637 commit_seq: Some(2),
2638 results: vec![OpResult::Applied { op_index: 0 }],
2639 },
2640 Frame::PushResult {
2641 client_commit_id: ids[2].clone(),
2642 status: PushStatus::Rejected,
2643 commit_seq: None,
2644 results: vec![OpResult::Error {
2645 op_index: 0,
2646 code: "sync.validation_failed".to_owned(),
2647 message: "rejected".to_owned(),
2648 retryable: false,
2649 }],
2650 },
2651 Frame::PushResult {
2652 client_commit_id: ids[3].clone(),
2653 status: PushStatus::Rejected,
2654 commit_seq: None,
2655 results: vec![OpResult::Error {
2656 op_index: 0,
2657 code: "sync.idempotency_cache_miss".to_owned(),
2658 message: "retry".to_owned(),
2659 retryable: true,
2660 }],
2661 },
2662 ],
2663 };
2664 let mut transport =
2665 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2666
2667 client.overlay_rebuild_count.set(0);
2668 client.outcome_prune_count.set(0);
2669 let outcome = client.process_response(&mut transport, response, &request_meta);
2670 let SyncOutcome::Ok(report) = outcome else {
2671 panic!("mixed push-result response failed");
2672 };
2673
2674 assert_eq!(report.applied, ids[..2]);
2675 assert_eq!(report.rejected, ids[2..3]);
2676 assert_eq!(report.retryable, ids[3..4]);
2677 assert_eq!(client.pending_commit_ids(), ids[3..4]);
2678 assert_eq!(
2679 client
2680 .query("SELECT id FROM tasks ORDER BY id", &[])
2681 .expect("query visible overlay"),
2682 vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2683 );
2684 assert_eq!(client.overlay_rebuild_count.get(), 1);
2685 assert_eq!(client.outcome_prune_count.get(), 1);
2686 }
2687
2688 #[test]
2689 fn secondary_unique_collision_preserves_existing_synced_row() {
2690 let client = SyncClient::new(
2691 "unique-upsert-test".to_owned(),
2692 &json!({
2693 "version": 1,
2694 "tables": [{
2695 "name": "tasks",
2696 "primaryKey": "id",
2697 "columns": [
2698 { "name": "id", "type": "string", "nullable": false },
2699 { "name": "project_id", "type": "string", "nullable": false },
2700 { "name": "title", "type": "string", "nullable": false }
2701 ],
2702 "scopes": [{ "pattern": "project:{project_id}" }],
2703 "indexes": [{
2704 "name": "tasks_by_project_title",
2705 "columns": ["project_id", "title"],
2706 "unique": true
2707 }]
2708 }]
2709 }),
2710 ClientLimits::default(),
2711 )
2712 .expect("test client");
2713 let table = client.schema.table("tasks").expect("tasks table");
2714 let sql = client.insert_row_sql(&base_table("tasks"), table);
2715
2716 client
2717 .conn
2718 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2719 .expect("insert first row");
2720 client
2721 .conn
2722 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
2723 .expect("update same primary key");
2724 client
2725 .conn
2726 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
2727 .expect("insert second row");
2728 assert!(client
2729 .conn
2730 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
2731 .is_err());
2732
2733 let rows = client
2734 .conn
2735 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
2736 .expect("prepare rows")
2737 .query_map([], |row| {
2738 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2739 })
2740 .expect("query rows")
2741 .collect::<Result<Vec<_>, _>>()
2742 .expect("collect rows");
2743 assert_eq!(
2744 rows,
2745 vec![
2746 ("t1".to_owned(), "updated".to_owned()),
2747 ("t2".to_owned(), "original".to_owned())
2748 ]
2749 );
2750 }
2751
2752 #[test]
2753 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
2754 let path = std::env::temp_dir().join(format!(
2755 "syncular-startup-intent-{}.db",
2756 uuid::Uuid::new_v4()
2757 ));
2758 let schema = json!({
2759 "version": 1,
2760 "tables": [{
2761 "name": "tasks",
2762 "primaryKey": "id",
2763 "columns": [
2764 { "name": "id", "type": "string", "nullable": false },
2765 { "name": "project_id", "type": "string", "nullable": false }
2766 ],
2767 "scopes": [{ "pattern": "project:{project_id}" }]
2768 }]
2769 });
2770
2771 {
2772 let mut first = SyncClient::open_path_with_identity(
2773 None,
2774 &schema,
2775 ClientLimits::default(),
2776 path.to_str().expect("UTF-8 temp path"),
2777 )
2778 .expect("first open");
2779 first
2780 .set_window(
2781 &WindowBase {
2782 table: "tasks".to_owned(),
2783 variable: "project_id".to_owned(),
2784 fixed_scopes: Vec::new(),
2785 params: None,
2786 },
2787 &["persisted".to_owned()],
2788 )
2789 .expect("persist window");
2790 }
2791
2792 let mut reopened = SyncClient::open_path_with_identity(
2793 None,
2794 &schema,
2795 ClientLimits::default(),
2796 path.to_str().expect("UTF-8 temp path"),
2797 )
2798 .expect("reopen");
2799 assert!(reopened.sync_needed());
2800 assert!(matches!(
2801 reopened.drain_sync_intents().as_slice(),
2802 [SyncIntent::Interactive]
2803 ));
2804 drop(reopened);
2805 std::fs::remove_file(path).expect("remove temp database");
2806 }
2807
2808 #[test]
2809 fn reopening_preserves_immutable_subscription_identity_and_progress() {
2810 let path = std::env::temp_dir().join(format!(
2811 "syncular-subscription-identity-{}.db",
2812 uuid::Uuid::new_v4()
2813 ));
2814 let schema = json!({
2815 "version": 1,
2816 "tables": [
2817 {
2818 "name": "tasks",
2819 "primaryKey": "id",
2820 "columns": [
2821 { "name": "id", "type": "string", "nullable": false },
2822 { "name": "project_id", "type": "string", "nullable": false }
2823 ],
2824 "scopes": [{ "pattern": "project:{project_id}" }]
2825 },
2826 {
2827 "name": "docs",
2828 "primaryKey": "id",
2829 "columns": [
2830 { "name": "id", "type": "string", "nullable": false },
2831 { "name": "org_id", "type": "string", "nullable": false },
2832 { "name": "project_id", "type": "string", "nullable": false }
2833 ],
2834 "scopes": [
2835 { "pattern": "org:{org_id}" },
2836 { "pattern": "project:{projectId}", "column": "project_id" }
2837 ]
2838 }
2839 ]
2840 });
2841
2842 {
2843 let mut first = SyncClient::open_path_with_identity(
2844 None,
2845 &schema,
2846 ClientLimits::default(),
2847 path.to_str().expect("UTF-8 temp path"),
2848 )
2849 .expect("first open");
2850 first
2851 .subscribe(
2852 "stable-subscription".to_owned(),
2853 "tasks".to_owned(),
2854 vec![(
2855 "project_id".to_owned(),
2856 vec!["p2".to_owned(), "p1".to_owned()],
2857 )],
2858 Some(r#"{"view":"v1"}"#.to_owned()),
2859 )
2860 .expect("persist subscription");
2861 let persisted = {
2862 let subscription = first
2863 .subs
2864 .iter_mut()
2865 .find(|subscription| subscription.id == "stable-subscription")
2866 .expect("subscription");
2867 subscription.cursor = 41;
2868 subscription.bootstrap_state = Some("resume-token".to_owned());
2869 subscription.effective = Some(vec![(
2870 "project_id".to_owned(),
2871 vec!["p1".to_owned(), "p2".to_owned()],
2872 )]);
2873 subscription.synced_once = true;
2874 subscription.clone()
2875 };
2876 first.persist_sub(&persisted).unwrap();
2877 }
2878
2879 let mut reopened = SyncClient::open_path_with_identity(
2880 None,
2881 &schema,
2882 ClientLimits::default(),
2883 path.to_str().expect("UTF-8 temp path"),
2884 )
2885 .expect("reopen");
2886 let progress = reopened
2887 .subscription_state("stable-subscription")
2888 .expect("persisted state");
2889 assert_eq!(progress.cursor, 41);
2890 assert!(progress.has_resume_token);
2891
2892 reopened
2893 .subscribe(
2894 "stable-subscription".to_owned(),
2895 "tasks".to_owned(),
2896 vec![(
2897 "project_id".to_owned(),
2898 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
2899 )],
2900 Some(r#"{"view":"v1"}"#.to_owned()),
2901 )
2902 .expect("canonical intent is idempotent");
2903 assert_eq!(
2904 reopened
2905 .subscription_state("stable-subscription")
2906 .expect("unchanged state")
2907 .cursor,
2908 progress.cursor
2909 );
2910
2911 for (table, scopes, params) in [
2912 (
2913 "tasks",
2914 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2915 Some(r#"{"view":"v1"}"#.to_owned()),
2916 ),
2917 (
2918 "tasks",
2919 vec![(
2920 "project_id".to_owned(),
2921 vec!["p2".to_owned(), "p1".to_owned()],
2922 )],
2923 Some(r#"{"view":"v2"}"#.to_owned()),
2924 ),
2925 (
2926 "docs",
2927 vec![
2928 ("org_id".to_owned(), vec!["o1".to_owned()]),
2929 ("projectId".to_owned(), vec!["p1".to_owned()]),
2930 ],
2931 Some(r#"{"view":"v1"}"#.to_owned()),
2932 ),
2933 ] {
2934 let error = reopened
2935 .subscribe(
2936 "stable-subscription".to_owned(),
2937 table.to_owned(),
2938 scopes,
2939 params,
2940 )
2941 .expect_err("identity rebind must fail");
2942 assert!(error.starts_with("client.subscription_intent_mismatch:"));
2943 assert_eq!(
2944 reopened
2945 .subscription_state("stable-subscription")
2946 .expect("unchanged state")
2947 .cursor,
2948 progress.cursor
2949 );
2950 }
2951
2952 drop(reopened);
2953 std::fs::remove_file(path).expect("remove temp database");
2954 }
2955
2956 #[test]
2957 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
2958 let path = std::env::temp_dir().join(format!(
2959 "syncular-satisfied-schema-floor-{}.db",
2960 uuid::Uuid::new_v4()
2961 ));
2962 let schema = json!({
2963 "version": 23,
2964 "tables": [{
2965 "name": "tasks",
2966 "primaryKey": "id",
2967 "columns": [
2968 { "name": "id", "type": "string", "nullable": false },
2969 { "name": "project_id", "type": "string", "nullable": false }
2970 ],
2971 "scopes": [{ "pattern": "project:{project_id}" }]
2972 }]
2973 });
2974
2975 {
2976 let mut first = SyncClient::open_path_with_identity(
2977 None,
2978 &schema,
2979 ClientLimits::default(),
2980 path.to_str().expect("UTF-8 temp path"),
2981 )
2982 .expect("first open");
2983 first
2984 .subscribe(
2985 "tasks".to_owned(),
2986 "tasks".to_owned(),
2987 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2988 None,
2989 )
2990 .expect("persist subscription");
2991 first.set_schema_floor(Some(SchemaFloor {
2992 required_schema_version: Some(22),
2993 latest_schema_version: Some(22),
2994 }));
2995 }
2996
2997 let mut reopened = SyncClient::open_path_with_identity(
2998 None,
2999 &schema,
3000 ClientLimits::default(),
3001 path.to_str().expect("UTF-8 temp path"),
3002 )
3003 .expect("reopen");
3004 assert!(reopened.schema_floor().is_none());
3005 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3006 assert!(reopened.sync_needed());
3007 assert!(matches!(
3008 reopened.drain_sync_intents().as_slice(),
3009 [SyncIntent::Interactive]
3010 ));
3011 drop(reopened);
3012 std::fs::remove_file(path).expect("remove temp database");
3013 }
3014
3015 #[test]
3016 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3017 let path = std::env::temp_dir().join(format!(
3018 "syncular-unsatisfied-schema-floor-{}.db",
3019 uuid::Uuid::new_v4()
3020 ));
3021 let schema = json!({
3022 "version": 1,
3023 "tables": [{
3024 "name": "tasks",
3025 "primaryKey": "id",
3026 "columns": [
3027 { "name": "id", "type": "string", "nullable": false },
3028 { "name": "project_id", "type": "string", "nullable": false }
3029 ],
3030 "scopes": [{ "pattern": "project:{project_id}" }]
3031 }]
3032 });
3033
3034 {
3035 let mut first = SyncClient::open_path_with_identity(
3036 None,
3037 &schema,
3038 ClientLimits::default(),
3039 path.to_str().expect("UTF-8 temp path"),
3040 )
3041 .expect("first open");
3042 first
3043 .subscribe(
3044 "tasks".to_owned(),
3045 "tasks".to_owned(),
3046 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3047 None,
3048 )
3049 .expect("persist subscription");
3050 first.set_schema_floor(Some(SchemaFloor {
3051 required_schema_version: Some(2),
3052 latest_schema_version: Some(2),
3053 }));
3054 }
3055
3056 let reopened = SyncClient::open_path_with_identity(
3057 None,
3058 &schema,
3059 ClientLimits::default(),
3060 path.to_str().expect("UTF-8 temp path"),
3061 )
3062 .expect("reopen");
3063 assert_eq!(
3064 reopened.schema_floor(),
3065 Some(&SchemaFloor {
3066 required_schema_version: Some(2),
3067 latest_schema_version: Some(2),
3068 })
3069 );
3070 assert!(!reopened.sync_needed());
3071 drop(reopened);
3072 std::fs::remove_file(path).expect("remove temp database");
3073 }
3074
3075 #[test]
3076 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3077 let path = std::env::temp_dir().join(format!(
3078 "syncular-indexed-column-bump-{}.db",
3079 uuid::Uuid::new_v4()
3080 ));
3081 let old_schema = json!({
3082 "version": 1,
3083 "tables": [
3084 {
3085 "name": "tasks",
3086 "primaryKey": "id",
3087 "columns": [
3088 { "name": "id", "type": "string", "nullable": false },
3089 { "name": "project_id", "type": "string", "nullable": false }
3090 ],
3091 "scopes": [{ "pattern": "project:{project_id}" }]
3092 },
3093 {
3094 "name": "legacy",
3095 "primaryKey": "id",
3096 "columns": [
3097 { "name": "id", "type": "string", "nullable": false },
3098 { "name": "project_id", "type": "string", "nullable": false }
3099 ],
3100 "scopes": [{ "pattern": "project:{project_id}" }]
3101 }
3102 ]
3103 });
3104 {
3105 let mut first = SyncClient::open_path_with_identity(
3106 None,
3107 &old_schema,
3108 ClientLimits::default(),
3109 path.to_str().expect("UTF-8 temp path"),
3110 )
3111 .expect("open old schema");
3112 first
3113 .subscribe(
3114 "legacy-sub".to_owned(),
3115 "legacy".to_owned(),
3116 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3117 None,
3118 )
3119 .expect("persist legacy subscription");
3120 }
3121
3122 let new_schema = json!({
3123 "version": 2,
3124 "tables": [{
3125 "name": "tasks",
3126 "primaryKey": "id",
3127 "columns": [
3128 { "name": "id", "type": "string", "nullable": false },
3129 { "name": "project_id", "type": "string", "nullable": false },
3130 { "name": "facility_membership_id", "type": "string", "nullable": true }
3131 ],
3132 "scopes": [{ "pattern": "project:{project_id}" }],
3133 "indexes": [{
3134 "name": "tasks_by_membership",
3135 "columns": ["project_id", "facility_membership_id"],
3136 "unique": false
3137 }]
3138 }]
3139 });
3140 let upgraded = SyncClient::open_path_with_identity(
3141 None,
3142 &new_schema,
3143 ClientLimits::default(),
3144 path.to_str().expect("UTF-8 temp path"),
3145 )
3146 .expect("open upgraded schema");
3147 let columns = upgraded
3148 .conn
3149 .prepare("PRAGMA table_info(tasks)")
3150 .expect("prepare columns")
3151 .query_map([], |row| row.get::<_, String>(1))
3152 .expect("query columns")
3153 .collect::<Result<Vec<_>, _>>()
3154 .expect("collect columns");
3155 assert!(columns.contains(&"facility_membership_id".to_owned()));
3156 let index_count: i64 = upgraded
3157 .conn
3158 .query_row(
3159 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3160 [],
3161 |row| row.get(0),
3162 )
3163 .expect("query index");
3164 assert_eq!(index_count, 1);
3165 assert!(upgraded.subscription_state("legacy-sub").is_none());
3166 drop(upgraded);
3167 std::fs::remove_file(path).expect("remove temp database");
3168 }
3169
3170 #[test]
3171 fn migrates_pre_envelope_outcome_journal_additively() {
3172 let path = std::env::temp_dir().join(format!(
3173 "syncular-outcome-migration-{}.db",
3174 uuid::Uuid::new_v4()
3175 ));
3176 let conn = Connection::open(&path).expect("open legacy database");
3177 conn.execute_batch(
3178 "CREATE TABLE _syncular_commit_outcomes (
3179 seq INTEGER PRIMARY KEY AUTOINCREMENT,
3180 client_commit_id TEXT NOT NULL UNIQUE,
3181 status TEXT NOT NULL,
3182 recorded_at_ms INTEGER NOT NULL,
3183 results_json TEXT NOT NULL,
3184 resolution TEXT NOT NULL DEFAULT 'active',
3185 resolved_at_ms INTEGER,
3186 replacement_client_commit_id TEXT);",
3187 )
3188 .expect("create legacy outcome journal");
3189 drop(conn);
3190 let schema = json!({
3191 "version": 1,
3192 "tables": [{
3193 "name": "tasks",
3194 "primaryKey": "id",
3195 "columns": [
3196 { "name": "id", "type": "string", "nullable": false },
3197 { "name": "project_id", "type": "string", "nullable": false }
3198 ],
3199 "scopes": [{ "pattern": "project:{project_id}" }]
3200 }]
3201 });
3202 let client = SyncClient::open_path(
3203 "migration-native".to_owned(),
3204 &schema,
3205 ClientLimits::default(),
3206 path.to_str().expect("UTF-8 temp path"),
3207 )
3208 .expect("migrate database");
3209 let has_operations = client
3210 .conn
3211 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3212 .expect("prepare table info")
3213 .query_map([], |row| row.get::<_, String>(1))
3214 .expect("query table info")
3215 .filter_map(Result::ok)
3216 .any(|column| column == "operations_json");
3217 assert!(has_operations);
3218 drop(client);
3219 std::fs::remove_file(path).expect("remove temp database");
3220 }
3221
3222 #[test]
3223 fn durable_conflict_outcome_and_resolution_survive_reopen() {
3224 let path = std::env::temp_dir().join(format!(
3225 "syncular-durable-outcome-{}.db",
3226 uuid::Uuid::new_v4()
3227 ));
3228 let schema = json!({
3229 "version": 1,
3230 "tables": [{
3231 "name": "tasks",
3232 "primaryKey": "id",
3233 "columns": [
3234 { "name": "id", "type": "string", "nullable": false },
3235 { "name": "project_id", "type": "string", "nullable": false }
3236 ],
3237 "scopes": [{ "pattern": "project:{project_id}" }]
3238 }]
3239 });
3240 let conflict = ConflictRecord {
3241 client_commit_id: "losing-commit".to_owned(),
3242 op_index: 0,
3243 table: "tasks".to_owned(),
3244 row_id: "t1".to_owned(),
3245 code: "sync.version_conflict".to_owned(),
3246 message: "stale base version".to_owned(),
3247 server_version: 2,
3248 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3249 conflict_columns: vec!["project_id".to_owned()],
3250 operation: Some(CommitOperation {
3251 table: "tasks".to_owned(),
3252 row_id: "t1".to_owned(),
3253 op: "upsert".to_owned(),
3254 base_version: Some(1),
3255 values: None,
3256 }),
3257 };
3258 let failed_operations = vec![
3259 OutboxOp {
3260 upsert: true,
3261 table: "tasks".to_owned(),
3262 row_id: "t1".to_owned(),
3263 base_version: Some(1),
3264 values: None,
3265 },
3266 OutboxOp {
3267 upsert: true,
3268 table: "tasks".to_owned(),
3269 row_id: "status-event-1".to_owned(),
3270 base_version: Some(0),
3271 values: None,
3272 },
3273 ];
3274
3275 {
3276 let mut first = SyncClient::open_path(
3277 "durable-native".to_owned(),
3278 &schema,
3279 ClientLimits::default(),
3280 path.to_str().expect("UTF-8 temp path"),
3281 )
3282 .expect("first open");
3283 first
3284 .begin_observation("test_outcome")
3285 .expect("begin outcome");
3286 first
3287 .persist_commit_outcome(
3288 "losing-commit",
3289 CommitOutcomeStatus::Conflict,
3290 &[CommitOperationOutcome::Conflict {
3291 conflict: conflict.clone(),
3292 }],
3293 Some(&failed_operations),
3294 )
3295 .expect("persist outcome");
3296 first.conflicts.push(conflict);
3297 first
3298 .finish_observation(
3299 "test_outcome",
3300 ChangeAccumulator {
3301 conflicts: true,
3302 outcomes: true,
3303 ..ChangeAccumulator::default()
3304 },
3305 )
3306 .expect("commit outcome");
3307 }
3308
3309 {
3310 let mut reopened = SyncClient::open_path(
3311 "durable-native".to_owned(),
3312 &schema,
3313 ClientLimits::default(),
3314 path.to_str().expect("UTF-8 temp path"),
3315 )
3316 .expect("reopen");
3317 assert_eq!(reopened.conflicts().len(), 1);
3318 assert_eq!(
3319 reopened.conflicts()[0].conflict_columns,
3320 vec!["project_id".to_owned()],
3321 "conflictColumns survives the journal round trip"
3322 );
3323 let outcome = reopened
3324 .commit_outcome("losing-commit")
3325 .expect("read outcome")
3326 .expect("outcome");
3327 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3328 let operations = outcome.operations.expect("aggregate envelope");
3329 assert_eq!(operations.len(), 2);
3330 assert_eq!(operations[1].row_id, "status-event-1");
3331 let resolved = reopened
3332 .resolve_commit_outcome(ResolveCommitOutcomeInput {
3333 client_commit_id: "losing-commit".to_owned(),
3334 resolution: CommitOutcomeResolution::ResolvedKeepServer,
3335 replacement_client_commit_id: None,
3336 })
3337 .expect("resolve");
3338 assert_eq!(
3339 resolved.resolution,
3340 CommitOutcomeResolution::ResolvedKeepServer
3341 );
3342 assert!(reopened.conflicts().is_empty());
3343 }
3344
3345 let reopened = SyncClient::open_path(
3346 "durable-native".to_owned(),
3347 &schema,
3348 ClientLimits::default(),
3349 path.to_str().expect("UTF-8 temp path"),
3350 )
3351 .expect("second reopen");
3352 assert!(reopened.conflicts().is_empty());
3353 assert_eq!(
3354 reopened
3355 .commit_outcome("losing-commit")
3356 .expect("read outcome")
3357 .expect("outcome")
3358 .resolution,
3359 CommitOutcomeResolution::ResolvedKeepServer
3360 );
3361 drop(reopened);
3362 std::fs::remove_file(path).expect("remove temp database");
3363 }
3364
3365 #[test]
3366 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3367 let path =
3368 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3369 let schema = json!({
3370 "version": 1,
3371 "tables": [{
3372 "name": "tasks",
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 let mut client = SyncClient::open_path_with_identity(
3382 Some("sidecar-client".to_owned()),
3383 &schema,
3384 ClientLimits::default(),
3385 path.to_str().expect("UTF-8 temp path"),
3386 )
3387 .expect("open owner");
3388 let base = WindowBase {
3389 table: "tasks".to_owned(),
3390 variable: "project_id".to_owned(),
3391 fixed_scopes: Vec::new(),
3392 params: None,
3393 };
3394 client
3395 .set_window(&base, &["one".to_owned()])
3396 .expect("set window");
3397 client
3398 .mutate(vec![Mutation::Upsert {
3399 table: "tasks".to_owned(),
3400 values: Map::from_iter([
3401 ("id".to_owned(), Value::from("t1")),
3402 ("project_id".to_owned(), Value::from("one")),
3403 ]),
3404 base_version: None,
3405 }])
3406 .expect("local mutate");
3407
3408 let coverage = [WindowCoverage {
3409 base,
3410 units: vec!["one".to_owned(), "missing".to_owned()],
3411 }];
3412 let owner = client
3413 .query_snapshot(
3414 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3415 &[],
3416 &coverage,
3417 )
3418 .expect("owner snapshot");
3419 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3420 let sidecar = reader
3421 .query_snapshot(
3422 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3423 &[],
3424 &coverage,
3425 )
3426 .expect("sidecar snapshot");
3427
3428 assert_eq!(sidecar.revision, owner.revision);
3429 assert_eq!(sidecar.rows, owner.rows);
3430 assert_eq!(
3431 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3432 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3433 );
3434 assert_eq!(sidecar.revision, "2");
3435 assert_eq!(sidecar.rows[0]["id"], "t1");
3436 assert_eq!(sidecar.rows[0]["server_version"], -1);
3437 assert!(!sidecar.coverage.complete);
3438 assert_eq!(sidecar.coverage.pending.len(), 1);
3439 assert_eq!(sidecar.coverage.missing.len(), 1);
3440
3441 drop(reader);
3442 drop(client);
3443 std::fs::remove_file(path).expect("remove temp database");
3444 }
3445
3446 #[test]
3447 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3448 let mut client = client();
3449 let image = Connection::open_in_memory().unwrap();
3450 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3451 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3452 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3453 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3454 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3455 let table = client.schema.table("tasks").unwrap().clone();
3456 let scopes = vec![("project_id".into(), vec!["p1".into()])];
3457 assert!(client
3458 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3459 .is_err());
3460 assert!(client.conn.is_autocommit());
3461 assert_eq!(
3462 client
3463 .query("SELECT count(*) AS n FROM tasks", &[])
3464 .unwrap()[0]["n"],
3465 1024
3466 );
3467 assert_eq!(client.drain_change_batches().len(), 1);
3468 image
3469 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3470 .unwrap();
3471 assert!(matches!(
3472 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3473 Ok(2050)
3474 ));
3475 assert_eq!(
3476 client
3477 .query("SELECT count(*) AS n FROM tasks", &[])
3478 .unwrap()[0]["n"],
3479 2050
3480 );
3481 assert_eq!(client.drain_change_batches().len(), 3);
3482 }
3483
3484 #[test]
3485 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3486 let mut client = client();
3487 let base = WindowBase {
3488 table: "tasks".into(),
3489 variable: "project_id".into(),
3490 fixed_scopes: Vec::new(),
3491 params: None,
3492 };
3493 client
3494 .set_window(&base, &["p1".into(), "p2".into()])
3495 .unwrap();
3496 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3497 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3498 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3499 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3500 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3501 client.drain_change_batches();
3502 assert!(client.set_window(&base, &["p2".into()]).is_err());
3503 assert!(client.conn.is_autocommit());
3504 assert_eq!(
3505 client
3506 .query("SELECT count(*) AS n FROM tasks", &[])
3507 .unwrap()[0]["n"],
3508 1027
3509 );
3510 assert_eq!(client.window_state(&base).units, vec!["p2"]);
3511 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
3512 assert_eq!(client.drain_change_batches().len(), 1);
3513 client
3514 .conn
3515 .execute_batch("DROP TRIGGER interrupt_evict")
3516 .unwrap();
3517 client.drain_pending_evictions().unwrap();
3518 assert!(client.load_pending_evictions().unwrap().is_empty());
3519 assert_eq!(
3520 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3521 "held"
3522 );
3523 assert_eq!(client.drain_change_batches().len(), 2);
3524 }
3525
3526 #[test]
3527 fn eviction_marker_failures_roll_back_window_changes() {
3528 let mut client = client();
3529 let base = WindowBase {
3530 table: "tasks".into(),
3531 variable: "project_id".into(),
3532 fixed_scopes: Vec::new(),
3533 params: None,
3534 };
3535 client.set_window(&base, &["p1".into()]).unwrap();
3536 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3537 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3538 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3539 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3540 let revision = client.local_revision();
3541 assert!(client.set_window(&base, &[]).is_err());
3542 assert!(client.conn.is_autocommit());
3543 assert_eq!(client.local_revision(), revision);
3544 assert_eq!(
3545 client
3546 .query("SELECT count(*) AS n FROM tasks", &[])
3547 .unwrap()[0]["n"],
3548 2050
3549 );
3550 assert_eq!(client.window_state(&base).units, vec!["p1"]);
3551 client.conn.execute_batch("DROP TRIGGER fail_marker;
3552 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3553 assert!(client.set_window(&base, &[]).is_err());
3554 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
3555 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3556 assert!(client.drain_pending_evictions().is_err());
3557 assert!(client.conn.is_autocommit());
3558 assert_eq!(
3559 client
3560 .query("SELECT count(*) AS n FROM tasks", &[])
3561 .unwrap()[0]["n"],
3562 2
3563 );
3564 assert!(client.set_window(&base, &["p1".into()]).is_err());
3565 assert!(client.conn.is_autocommit());
3566 assert!(client.window_state(&base).units.is_empty());
3567 assert!(client.subs.is_empty());
3568 assert_eq!(
3569 client
3570 .query(
3571 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
3572 &[]
3573 )
3574 .unwrap()[0]["n"],
3575 1
3576 );
3577 client
3578 .conn
3579 .execute_batch("DROP TRIGGER fail_marker")
3580 .unwrap();
3581 client.set_window(&base, &["p1".into()]).unwrap();
3582 assert_eq!(client.window_state(&base).units, vec!["p1"]);
3583 }
3584
3585 #[test]
3586 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
3587 let mut client = client();
3588 client
3589 .conn
3590 .execute(
3591 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
3592 [],
3593 )
3594 .unwrap();
3595 assert_eq!(
3596 client.drain_pending_evictions().unwrap_err(),
3597 "sync.local_corrupt: invalid pending eviction scopes"
3598 );
3599 assert!(client.conn.is_autocommit());
3600 assert!(client.enqueue_startup_sync_if_needed().is_err());
3601 }
3602
3603 #[test]
3604 fn image_chunks_reject_null_primary_keys() {
3605 let mut client = client();
3606 let image = Connection::open_in_memory().unwrap();
3607 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3608 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
3609 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
3610 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3611 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
3612 let table = client.schema.table("tasks").unwrap().clone();
3613 assert!(client
3614 .apply_sqlite_image(
3615 &image,
3616 &table,
3617 true,
3618 &[("project_id".into(), vec!["p1".into()])],
3619 1025,
3620 7,
3621 "digest"
3622 )
3623 .is_err());
3624 assert!(client.conn.is_autocommit());
3625 assert_eq!(
3626 client
3627 .query("SELECT count(*) AS n FROM tasks", &[])
3628 .unwrap()[0]["n"],
3629 0
3630 );
3631 }
3632
3633 #[test]
3634 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
3635 for images in [false, true] {
3636 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
3637 "name":name,"primaryKey":"id","columns":[
3638 {"name":"id","type":"string","nullable":false},
3639 {"name":"project_id","type":"string","nullable":false},
3640 {"name":"title","type":"string","nullable":false}],
3641 "scopes":[{"pattern":"project:{project_id}"}],
3642 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
3643 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
3644 })))});
3645 let mut actual =
3646 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
3647 let mut reference =
3648 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
3649 let scopes = vec![("project_id".into(), vec!["p1".into()])];
3650 for client in [&mut actual, &mut reference] {
3651 client
3652 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
3653 .unwrap();
3654 client.subs[0].effective = Some(scopes.clone());
3655 for (table, id, title) in [
3656 ("tasks", "a", "original"),
3657 ("tasks", "b", "occupied"),
3658 ("tasks", "c", "deleted"),
3659 ("notes", "n", "untouched"),
3660 ] {
3661 client
3662 .write_base_row(
3663 table,
3664 &vec![
3665 Some(ColumnValue::String(id.into())),
3666 Some(ColumnValue::String("p1".into())),
3667 Some(ColumnValue::String(title.into())),
3668 ],
3669 1,
3670 )
3671 .unwrap();
3672 }
3673 client.rebuild_overlay_if_dirty();
3674 client
3675 .mutate(vec![
3676 Mutation::Upsert {
3677 table: "tasks".into(),
3678 values: Map::from_iter([
3679 ("id".into(), Value::from("a")),
3680 ("project_id".into(), Value::from("p1")),
3681 ("title".into(), Value::from("occupied")),
3682 ]),
3683 base_version: None,
3684 },
3685 Mutation::Delete {
3686 table: "tasks".into(),
3687 row_id: "c".into(),
3688 base_version: None,
3689 },
3690 Mutation::Upsert {
3691 table: "tasks".into(),
3692 values: Map::from_iter([
3693 ("id".into(), Value::from("local")),
3694 ("project_id".into(), Value::from("p1")),
3695 ("title".into(), Value::from("local value")),
3696 ]),
3697 base_version: None,
3698 },
3699 Mutation::Upsert {
3700 table: "notes".into(),
3701 values: Map::from_iter([
3702 ("id".into(), Value::from("n")),
3703 ("project_id".into(), Value::from("p1")),
3704 ("title".into(), Value::from("pending note")),
3705 ]),
3706 base_version: None,
3707 },
3708 ])
3709 .unwrap();
3710 }
3711 let pending = actual.pending_commit_ids();
3712 actual.overlay_rebuild_count.set(0);
3713 for (id, title) in [
3714 ("b", "available"),
3715 ("b", "occupied"),
3716 ("a", "server original"),
3717 ("d", "local value"),
3718 ("d", "released"),
3719 ("c", "redelivered"),
3720 ] {
3721 let table = actual.schema.table("tasks").unwrap().clone();
3722 let values = vec![
3723 Some(ColumnValue::String(id.into())),
3724 Some(ColumnValue::String("p1".into())),
3725 Some(ColumnValue::String(title.into())),
3726 ];
3727 if images {
3728 let image = Connection::open_in_memory().unwrap();
3729 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3730 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3731 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
3732 image
3733 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
3734 .unwrap();
3735 assert!(matches!(
3736 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
3737 Ok(1)
3738 ));
3739 } else {
3740 let segment = RowsSegment {
3741 table: "tasks".into(),
3742 schema_version: 1,
3743 columns: table.wire_columns.clone(),
3744 blocks: vec![vec![ssp2::segment::SegmentRow {
3745 server_version: 2,
3746 values: values.clone(),
3747 }]],
3748 };
3749 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
3750 }
3751 reference.write_base_row("tasks", &values, 2).unwrap();
3752 reference.rebuild_overlay();
3753 for table in ["tasks", "notes"] {
3754 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")] {
3755 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
3756 }
3757 }
3758 assert_eq!(actual.pending_commit_ids(), pending);
3759 }
3760 assert_eq!(
3761 actual.overlay_rebuild_count.get(),
3762 0,
3763 "each chunk must leave unrelated tables alone"
3764 );
3765 let before = actual
3766 .query("SELECT * FROM tasks ORDER BY id", &[])
3767 .unwrap();
3768 let fts_before = actual
3769 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
3770 .unwrap();
3771 let revision = actual.local_revision();
3772 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
3773 let table = actual.schema.table("tasks").unwrap().clone();
3774 let segment = RowsSegment {
3775 table: "tasks".into(),
3776 schema_version: 1,
3777 columns: table.wire_columns.clone(),
3778 blocks: vec![vec![ssp2::segment::SegmentRow {
3779 server_version: 3,
3780 values: vec![
3781 Some(ColumnValue::String("b".into())),
3782 Some(ColumnValue::String("p1".into())),
3783 Some(ColumnValue::String("fresh".into())),
3784 ],
3785 }]],
3786 };
3787 assert!(actual.apply_segment(0, &segment, false).is_err());
3788 assert!(actual.conn.is_autocommit());
3789 assert_eq!(
3790 actual
3791 .query("SELECT * FROM tasks ORDER BY id", &[])
3792 .unwrap(),
3793 before
3794 );
3795 assert_eq!(
3796 actual
3797 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
3798 .unwrap(),
3799 fts_before
3800 );
3801 assert_eq!(actual.local_revision(), revision);
3802 assert_eq!(actual.pending_commit_ids(), pending);
3803 }
3804 }
3805
3806 #[test]
3807 fn import_reconciliation_preserves_typed_pending_primary_keys() {
3808 for (kind, a, b) in [
3809 ("string", json!("a"), json!("b")),
3810 ("integer", json!(1), json!(2)),
3811 ("boolean", json!(true), json!(false)),
3812 ("json", json!("1"), json!("2")),
3813 ] {
3814 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}]}]});
3815 for images in [false, true] {
3816 let mut client =
3817 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
3818 let table = client.schema.table("tasks").unwrap().clone();
3819 client
3820 .subscribe("tasks".into(), "tasks".into(), vec![], None)
3821 .unwrap();
3822 for (id, title) in [(&a, "original"), (&b, "occupied")] {
3823 client
3824 .write_base_row(
3825 "tasks",
3826 &vec![
3827 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
3828 Some(ColumnValue::String(title.into())),
3829 ],
3830 1,
3831 )
3832 .unwrap();
3833 }
3834 client.rebuild_overlay_if_dirty();
3835 client
3836 .mutate(vec![Mutation::Upsert {
3837 table: "tasks".into(),
3838 values: Map::from_iter([
3839 ("id".into(), a.clone()),
3840 ("title".into(), json!("occupied")),
3841 ]),
3842 base_version: None,
3843 }])
3844 .unwrap();
3845 client.overlay_rebuild_count.set(0);
3846 for (title, expected) in [("available", "occupied"), ("occupied", "original")] {
3847 let values = vec![
3848 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
3849 Some(ColumnValue::String(title.into())),
3850 ];
3851 if images {
3852 let image = Connection::open_in_memory().unwrap();
3853 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3854 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3855 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
3856 image
3857 .execute(
3858 "INSERT INTO tasks VALUES (?1,?2,2)",
3859 rusqlite::params![RowParam::Cell(&values[0]), title],
3860 )
3861 .unwrap();
3862 assert!(
3863 matches!(
3864 client.apply_sqlite_image(
3865 &image,
3866 &table,
3867 false,
3868 &[],
3869 1,
3870 7,
3871 "digest"
3872 ),
3873 Ok(1)
3874 ),
3875 "{kind}, images={images}"
3876 );
3877 } else {
3878 let segment = RowsSegment {
3879 table: "tasks".into(),
3880 schema_version: 1,
3881 columns: table.wire_columns.clone(),
3882 blocks: vec![vec![ssp2::segment::SegmentRow {
3883 server_version: 2,
3884 values,
3885 }]],
3886 };
3887 assert!(
3888 matches!(client.apply_segment(0, &segment, false), Ok(1)),
3889 "{kind}"
3890 );
3891 }
3892 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
3893 let actual: String = client
3894 .conn
3895 .query_row(
3896 "SELECT title FROM tasks WHERE id=?1",
3897 [RowParam::Cell(&key)],
3898 |row| row.get(0),
3899 )
3900 .unwrap();
3901 assert_eq!(actual, expected, "{kind}, images={images}");
3902 }
3903 assert_eq!(client.overlay_rebuild_count.get(), 0);
3904 }
3905 }
3906 }
3907
3908 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
3909 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
3910 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
3911 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
3912 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
3913 })))});
3914 let mut client =
3915 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
3916 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();
3917 client.rebuild_overlay();
3918 client
3919 .mutate(vec![Mutation::Upsert {
3920 table: "tasks".into(),
3921 values: Map::from_iter([
3922 ("id".into(), json!("offline")),
3923 ("project_id".into(), json!("p1")),
3924 ("title".into(), json!("pending edit")),
3925 ]),
3926 base_version: None,
3927 }])
3928 .unwrap();
3929 let image = Connection::open_in_memory().unwrap();
3930 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3931 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
3932 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();
3933 image
3934 .execute(
3935 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
3936 [rows],
3937 )
3938 .unwrap();
3939 client.overlay_rebuild_count.set(0);
3940 let table = client.schema.table("tasks").unwrap().clone();
3941 (client, image, table)
3942 }
3943
3944 #[test]
3945 fn large_unique_import_copies_no_unrelated_tables() {
3946 let (mut client, image, table) = unique_import_fixture(10240);
3947 let pending = client.pending_commit_ids();
3948 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
3949 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
3950 assert!(matches!(
3951 client.apply_sqlite_image(
3952 &image,
3953 &table,
3954 true,
3955 &[("project_id".into(), vec!["p1".into()])],
3956 10240,
3957 7,
3958 "digest"
3959 ),
3960 Ok(10240)
3961 ));
3962 assert_eq!(client.overlay_rebuild_count.get(), 0);
3963 assert_eq!(client.pending_commit_ids(), pending);
3964 assert_eq!(
3965 client
3966 .query("SELECT count(*) AS n FROM tasks", &[])
3967 .unwrap()[0]["n"],
3968 10241
3969 );
3970 assert_eq!(
3971 client
3972 .query("SELECT count(*) AS n FROM tasks_fts", &[])
3973 .unwrap()[0]["n"],
3974 10241
3975 );
3976 assert_eq!(
3977 client
3978 .query("SELECT count(*) AS n FROM notes_fts", &[])
3979 .unwrap()[0]["n"],
3980 8192
3981 );
3982 }
3983
3984 #[test]
3985 #[ignore = "manual scaling benchmark; see bench/README.md"]
3986 fn benchmark_unique_image_import() {
3987 let mut samples = Vec::new();
3988 for rows in [10240, 51200, 102400] {
3989 for trial in 0..4 {
3990 let (mut client, image, table) = unique_import_fixture(rows);
3991 let start = std::time::Instant::now();
3992 assert!(
3993 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)
3994 );
3995 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
3996 assert_eq!(
3997 client
3998 .query("SELECT count(*) AS n FROM tasks", &[])
3999 .unwrap()[0]["n"],
4000 rows + 1
4001 );
4002 assert_eq!(
4003 client
4004 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4005 .unwrap()[0]["n"],
4006 rows + 1
4007 );
4008 assert_eq!(
4009 client
4010 .query("SELECT count(*) AS n FROM notes_fts", &[])
4011 .unwrap()[0]["n"],
4012 8192
4013 );
4014 assert_eq!(client.pending_commit_ids().len(), 1);
4015 if trial > 0 {
4016 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4017 }
4018 }
4019 }
4020 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});
4021 let output = serde_json::to_string_pretty(&artifact).unwrap();
4022 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4023 use std::io::Write;
4024 let mut file = std::fs::OpenOptions::new()
4025 .write(true)
4026 .create_new(true)
4027 .open(path)
4028 .unwrap();
4029 file.write_all(output.as_bytes()).unwrap();
4030 } else {
4031 println!("{output}");
4032 }
4033 }
4034
4035 #[test]
4036 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4037 let schema = json!({
4038 "version": 1,
4039 "tables": [{
4040 "name": "catalogue_codes",
4041 "primaryKey": "id",
4042 "columns": [
4043 { "name": "id", "type": "string", "nullable": false },
4044 { "name": "release_id", "type": "string", "nullable": false },
4045 { "name": "code", "type": "string", "nullable": false },
4046 { "name": "title", "type": "string", "nullable": false }
4047 ],
4048 "scopes": [{ "pattern": "release:{release_id}" }],
4049 "ftsIndexes": [{
4050 "name": "catalogue_codes_fts",
4051 "columns": ["code", "title"],
4052 "tokenize": "unicode61 remove_diacritics 2"
4053 }]
4054 }]
4055 });
4056 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4057 .expect("FTS5 client");
4058 let insert_trigger: String = client
4059 .conn
4060 .query_row(
4061 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4062 [],
4063 |row| row.get(0),
4064 )
4065 .expect("insert trigger");
4066 let replace_guard: String = client
4067 .conn
4068 .query_row(
4069 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4070 [],
4071 |row| row.get(0),
4072 )
4073 .expect("replace guard");
4074 assert!(!insert_trigger.contains("DELETE FROM"));
4075 assert!(replace_guard.contains("BEFORE INSERT"));
4076 assert!(replace_guard.contains("WHEN EXISTS"));
4077 let search = |client: &SyncClient, query: &str| {
4078 client
4079 .query(
4080 "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",
4081 &[Value::from(query)],
4082 )
4083 .expect("FTS query")
4084 };
4085
4086 client
4087 .mutate(vec![Mutation::Upsert {
4088 table: "catalogue_codes".to_owned(),
4089 values: Map::from_iter([
4090 ("id".to_owned(), Value::from("c1")),
4091 ("release_id".to_owned(), Value::from("r1")),
4092 ("code".to_owned(), Value::from("A01")),
4093 ("title".to_owned(), Value::from("Cholera")),
4094 ]),
4095 base_version: None,
4096 }])
4097 .expect("insert code");
4098 assert_eq!(search(&client, "cholera").len(), 1);
4099 let prior = client
4100 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4101 .unwrap();
4102 client
4103 .conn
4104 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4105 .unwrap();
4106 client.create_synced_tables().unwrap();
4107 assert_eq!(
4108 client
4109 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4110 .unwrap(),
4111 prior
4112 );
4113 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();
4114 let rendered = serde_json::to_string(&plan).unwrap();
4115 assert!(rendered.contains("COVERING INDEX"));
4116 assert!(rendered.contains("INDEX 0:="));
4117 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4118 assert_eq!(search(&client, "cholera").len(), 1);
4119
4120 client
4121 .mutate(vec![Mutation::Upsert {
4122 table: "catalogue_codes".to_owned(),
4123 values: Map::from_iter([
4124 ("id".to_owned(), Value::from("c1")),
4125 ("release_id".to_owned(), Value::from("r1")),
4126 ("code".to_owned(), Value::from("A01")),
4127 ("title".to_owned(), Value::from("Enteric infection")),
4128 ]),
4129 base_version: None,
4130 }])
4131 .expect("update code");
4132 assert!(search(&client, "cholera").is_empty());
4133 assert_eq!(search(&client, "enteric").len(), 1);
4134
4135 client
4136 .mutate(vec![Mutation::Delete {
4137 table: "catalogue_codes".to_owned(),
4138 row_id: "c1".to_owned(),
4139 base_version: None,
4140 }])
4141 .expect("delete code");
4142 assert!(search(&client, "enteric").is_empty());
4143 }
4144
4145 #[test]
4146 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4147 let schema = json!({
4148 "version": 1,
4149 "tables": [{
4150 "name": "patient_notes",
4151 "primaryKey": "id",
4152 "columns": [
4153 { "name": "id", "type": "string", "nullable": false },
4154 { "name": "practice_id", "type": "string", "nullable": false },
4155 { "name": "encryption_key_id", "type": "string", "nullable": false },
4156 { "name": "title", "type": "string", "nullable": false }
4157 ],
4158 "scopes": [{ "pattern": "practice:{practice_id}" }],
4159 "ftsIndexes": [{
4160 "name": "patient_notes_fts",
4161 "columns": ["title"],
4162 "tokenize": "unicode61 remove_diacritics 2"
4163 }]
4164 }]
4165 });
4166 let mut client = SyncClient::new(
4167 "local-purge-test".to_owned(),
4168 &schema,
4169 ClientLimits::default(),
4170 )
4171 .expect("local purge client");
4172 client
4173 .conn
4174 .execute(
4175 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4176 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4177 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4178 [],
4179 )
4180 .expect("seed base rows");
4181 client.overlay_dirty.set(true);
4182 client.rebuild_overlay_if_dirty();
4183
4184 let note = |id: &str, key_id: &str, title: &str| {
4185 Map::from_iter([
4186 ("id".to_owned(), Value::from(id)),
4187 ("practice_id".to_owned(), Value::from("practice-1")),
4188 ("encryption_key_id".to_owned(), Value::from(key_id)),
4189 ("title".to_owned(), Value::from(title)),
4190 ])
4191 };
4192 let doomed = client
4193 .mutate(vec![
4194 Mutation::Upsert {
4195 table: "patient_notes".to_owned(),
4196 values: note("target", "key-revoked", "Target changed"),
4197 base_version: None,
4198 },
4199 Mutation::Upsert {
4200 table: "patient_notes".to_owned(),
4201 values: note("unrelated", "key-held", "Unrelated changed"),
4202 base_version: None,
4203 },
4204 ])
4205 .expect("doomed commit");
4206 let kept = client
4207 .mutate(vec![Mutation::Upsert {
4208 table: "patient_notes".to_owned(),
4209 values: note("kept", "key-held", "Kept optimistic"),
4210 base_version: None,
4211 }])
4212 .expect("kept commit");
4213 client.drain_change_batches();
4214
4215 let input = LocalDataPurgeInput {
4216 purge_id: "purge-001".to_owned(),
4217 targets: vec![LocalDataPurgeTarget {
4218 table: "patient_notes".to_owned(),
4219 selectors: BTreeMap::from([(
4220 "encryption_key_id".to_owned(),
4221 vec!["key-revoked".to_owned()],
4222 )]),
4223 }],
4224 };
4225 assert_eq!(
4226 client.purge_local_data(&input).expect("apply purge"),
4227 LocalDataPurgeResult {
4228 already_applied: false,
4229 purged_rows: 1,
4230 dropped_commits: 1,
4231 }
4232 );
4233 let rows = client
4234 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
4235 .expect("visible rows");
4236 assert_eq!(rows.len(), 2);
4237 assert_eq!(rows[0]["id"], "kept");
4238 assert_eq!(rows[1]["id"], "unrelated");
4239 assert_eq!(rows[1]["title"], "Unrelated original");
4240 let fts = client
4241 .query(
4242 "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'",
4243 &[],
4244 )
4245 .expect("fts query");
4246 assert!(fts.is_empty());
4247 assert_eq!(client.outbox.len(), 1);
4248 assert_eq!(client.outbox[0].client_commit_id, kept);
4249 let outcome = client
4250 .commit_outcome(&doomed)
4251 .expect("read doomed outcome")
4252 .expect("doomed outcome");
4253 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
4254 match &outcome.results[0] {
4255 CommitOperationOutcome::Error { rejection } => {
4256 assert_eq!(rejection.code, "client.local_data_purged");
4257 assert_eq!(rejection.client_commit_id, doomed);
4258 }
4259 other => panic!("expected local purge rejection, got {other:?}"),
4260 }
4261 assert_eq!(client.drain_change_batches().len(), 1);
4262 assert_eq!(
4263 client.purge_local_data(&input).expect("retry purge"),
4264 LocalDataPurgeResult {
4265 already_applied: true,
4266 purged_rows: 0,
4267 dropped_commits: 0,
4268 }
4269 );
4270 let conflicting = LocalDataPurgeInput {
4271 purge_id: input.purge_id.clone(),
4272 targets: vec![LocalDataPurgeTarget {
4273 table: "patient_notes".to_owned(),
4274 selectors: BTreeMap::from([(
4275 "encryption_key_id".to_owned(),
4276 vec!["key-held".to_owned()],
4277 )]),
4278 }],
4279 };
4280 assert!(client
4281 .purge_local_data(&conflicting)
4282 .expect_err("id collision must fail")
4283 .contains("already used with a different plan"));
4284 }
4285}
4286
4287impl SubState {
4288 fn name(self) -> &'static str {
4289 match self {
4290 SubState::Active => "active",
4291 SubState::Revoked => "revoked",
4292 SubState::Failed => "failed",
4293 }
4294 }
4295
4296 fn parse(value: &str) -> Self {
4297 match value {
4298 "revoked" => Self::Revoked,
4299 "failed" => Self::Failed,
4300 _ => Self::Active,
4301 }
4302 }
4303}
4304
4305#[derive(Debug, Clone)]
4306struct Subscription {
4307 id: String,
4308 table: String,
4309 requested: Vec<(String, Vec<String>)>,
4310 params: Option<String>,
4311 cursor: i64,
4312 bootstrap_state: Option<String>,
4314 state: SubState,
4315 reason_code: Option<String>,
4316 effective: Option<Vec<(String, Vec<String>)>>,
4319 synced_once: bool,
4320}
4321
4322#[derive(Debug, Clone)]
4323struct OutboxOp {
4324 upsert: bool,
4325 table: String,
4326 row_id: String,
4327 base_version: Option<i64>,
4328 values: Option<Map<String, Value>>,
4332}
4333
4334impl From<&OutboxOp> for CommitOperation {
4335 fn from(operation: &OutboxOp) -> Self {
4336 Self {
4337 table: operation.table.clone(),
4338 row_id: operation.row_id.clone(),
4339 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4340 base_version: operation.base_version,
4341 values: operation.values.clone(),
4342 }
4343 }
4344}
4345
4346#[derive(Debug, Clone)]
4347struct OutboxCommit {
4348 client_commit_id: String,
4349 ops: Vec<OutboxOp>,
4350}
4351
4352fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
4356 commit
4357 .ops
4358 .iter()
4359 .filter(|operation| operation.upsert)
4360 .map(|operation| {
4361 (
4362 operation.table.as_str(),
4363 operation
4364 .values
4365 .as_ref()
4366 .map(|values| values.keys().map(String::as_str).collect())
4367 .unwrap_or_default(),
4368 )
4369 })
4370 .collect()
4371}
4372
4373#[derive(Debug, Clone)]
4374struct CompiledLocalDataPurgeTarget {
4375 table: String,
4376 selectors: Vec<(String, Vec<String>)>,
4377}
4378
4379struct StoredCommitOutcomeRow {
4380 sequence: i64,
4381 client_commit_id: String,
4382 status: String,
4383 recorded_at_ms: i64,
4384 results_json: String,
4385 operations_json: Option<String>,
4386 resolution: String,
4387 resolved_at_ms: Option<i64>,
4388 replacement_client_commit_id: Option<String>,
4389}
4390
4391enum SectionError {
4394 FailClosed,
4395 Abort(String, String),
4396}
4397
4398struct RequestMeta {
4399 pushed_ids: Vec<String>,
4400 fresh: Vec<(String, bool)>,
4403 accept: u8,
4404 deferred_commits: usize,
4408}
4409
4410#[derive(Default)]
4411struct ChangeAccumulator {
4412 tables: BTreeMap<String, Option<BTreeSet<String>>>,
4414 windows: BTreeMap<(String, String), BTreeSet<String>>,
4415 status: bool,
4416 conflicts: bool,
4417 rejections: bool,
4418 outcomes: bool,
4419}
4420
4421impl ChangeAccumulator {
4422 fn table(&mut self, table: &str) {
4423 self.tables.insert(table.to_owned(), None);
4424 }
4425
4426 fn scope(&mut self, table: &str, key: String) {
4427 match self.tables.get_mut(table) {
4428 Some(None) => {}
4429 Some(Some(keys)) => {
4430 keys.insert(key);
4431 }
4432 None => {
4433 self.tables
4434 .insert(table.to_owned(), Some(BTreeSet::from([key])));
4435 }
4436 }
4437 }
4438
4439 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4440 self.windows
4441 .entry((base_key.to_owned(), table.to_owned()))
4442 .or_default()
4443 .insert(unit.to_owned());
4444 }
4445
4446 fn touched(&self) -> bool {
4447 !self.tables.is_empty()
4448 || !self.windows.is_empty()
4449 || self.status
4450 || self.conflicts
4451 || self.rejections
4452 || self.outcomes
4453 }
4454}
4455
4456const PUSH_OPS_PER_REQUEST: usize = 500;
4462const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4463const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4464const MAX_LOCAL_PURGE_VALUES: usize = 128;
4465const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4466pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4467
4468pub struct SyncClient {
4469 #[cfg(feature = "bench-internals")]
4470 benchmark_phases: Recorder,
4471 conn: Connection,
4472 schema: ClientSchema,
4473 client_id: String,
4474 limits: ClientLimits,
4475 subs: Vec<Subscription>,
4476 outbox: Vec<OutboxCommit>,
4477 conflicts: Vec<ConflictRecord>,
4478 rejections: Vec<RejectionRecord>,
4479 schema_floor: Option<SchemaFloor>,
4480 lease_state: Option<LeaseState>,
4482 stopped: bool,
4484 upgrading: bool,
4486 sync_needed: bool,
4488 realtime_connected: bool,
4489 presence: HashMap<String, HashMap<String, PresencePeer>>,
4491 now_ms: Option<i64>,
4494 encryption: crate::values::EncryptionConfig,
4498 security_preflight: bool,
4501 previous_version: Option<PreviousVersionContextConfig>,
4505 insert_sql: RefCell<HashMap<String, String>>,
4511 overlay_dirty: Cell<bool>,
4515 #[cfg(test)]
4518 overlay_rebuild_count: Cell<usize>,
4519 #[cfg(test)]
4522 outcome_prune_count: Cell<usize>,
4523 progress: ProgressObserver,
4525 change_queue: VecDeque<ClientChangeBatch>,
4526 sync_intent_queue: VecDeque<SyncIntent>,
4527 retry_delay_ms: u64,
4529 last_round: Option<DiagnosticLastRound>,
4530 last_change: Option<DiagnosticLastChange>,
4531}
4532
4533pub(crate) fn quote_ident(name: &str) -> String {
4534 format!("\"{}\"", name.replace('"', "\"\""))
4535}
4536pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
4540 conn.query_row(
4541 "SELECT value FROM _syncular_meta WHERE key = ?1",
4542 rusqlite::params![key],
4543 |row| row.get::<_, String>(0),
4544 )
4545 .ok()
4546}
4547
4548pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
4549 let _ = conn.execute(
4550 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
4551 rusqlite::params![key, value],
4552 );
4553}
4554
4555pub(crate) fn meta_delete(conn: &Connection, key: &str) {
4556 let _ = conn.execute(
4557 "DELETE FROM _syncular_meta WHERE key = ?1",
4558 rusqlite::params![key],
4559 );
4560}
4561
4562fn row_id_predicate(table: &TableSchema) -> String {
4564 let key = quote_ident(&table.primary_key);
4565 let text_match = format!("CAST({key} AS TEXT) = ?1");
4566 match table.columns[table.pk_index].ty {
4567 ColumnType::String | ColumnType::Json => {
4568 format!("{key} = ?1 AND {text_match}")
4569 }
4570 ColumnType::Integer | ColumnType::Boolean => {
4574 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
4575 }
4576 _ => text_match,
4579 }
4580}
4581
4582fn is_local_operation_code_like(value: &str) -> bool {
4583 let bytes = value.as_bytes();
4584 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
4585 && bytes
4586 .iter()
4587 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
4588}
4589
4590fn base_table(name: &str) -> String {
4591 quote_ident(&format!("_syncular_base_{name}"))
4592}
4593
4594type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
4599
4600fn window_base_key(base: &WindowBase) -> String {
4601 format!(
4602 "{}\0{}\0{}",
4603 base.table,
4604 base.variable,
4605 canonical_scope_json(&base.fixed_scopes)
4606 )
4607}
4608
4609fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
4611 let mut scopes = base.fixed_scopes.clone();
4612 scopes.retain(|(k, _)| k != &base.variable);
4613 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
4614 scopes
4615}
4616
4617fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
4621 let canonical = canonical_scope_json(&unit_scopes(base, unit));
4622 let digest = Sha256::digest(canonical.as_bytes());
4623 let hex = bytes_to_hex(&digest);
4624 format!("w:{}:{}", base.table, &hex[..16])
4625}
4626
4627fn visible_table(name: &str) -> String {
4628 quote_ident(name)
4629}
4630
4631const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
4632
4633fn is_synced_table_name(name: &str) -> bool {
4636 if name.starts_with("sqlite_") {
4637 return false;
4638 }
4639 if name.starts_with("_syncular_base_") {
4640 return true; }
4642 !name.starts_with("_syncular_")
4644}
4645
4646fn blob_id_for(bytes: &[u8]) -> String {
4648 let digest = Sha256::digest(bytes);
4649 format!("sha256:{}", bytes_to_hex(&digest))
4650}
4651
4652enum RowParam<'a> {
4656 Cell(&'a Option<ColumnValue>),
4657 Version(i64),
4658}
4659
4660impl rusqlite::ToSql for RowParam<'_> {
4661 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
4662 Ok(match self {
4663 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
4664 RowParam::Cell(cell) => match cell {
4665 None => ToSqlOutput::Owned(SqlValue::Null),
4666 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
4667 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
4668 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
4669 Some(ColumnValue::Boolean(b)) => {
4670 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
4671 }
4672 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
4673 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
4674 }
4675 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
4677 ToSqlOutput::Borrowed(ValueRef::Blob(b))
4678 }
4679 },
4680 })
4681 }
4682}
4683
4684fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
4686 match param.to_sql().map_err(|error| error.to_string())? {
4687 ToSqlOutput::Owned(value) => Ok(value),
4688 ToSqlOutput::Borrowed(value) => {
4689 SqlValue::try_from(value).map_err(|error| error.to_string())
4690 }
4691 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
4692 }
4693}
4694
4695fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
4703 use ssp2::segment::ColumnType;
4704 let mismatch = || {
4705 Err(format!(
4706 "image column {:?} holds a value of the wrong type",
4707 column.name
4708 ))
4709 };
4710 match value {
4711 ValueRef::Null => {
4712 if !column.nullable {
4713 return Err(format!(
4714 "image column {:?} is NULL but not nullable",
4715 column.name
4716 ));
4717 }
4718 Ok(ToSqlOutput::Owned(SqlValue::Null))
4719 }
4720 ValueRef::Integer(i) => match column.ty {
4721 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
4722 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
4723 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
4724 _ => mismatch(),
4725 },
4726 ValueRef::Real(_) => match column.ty {
4727 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
4728 _ => mismatch(),
4729 },
4730 ValueRef::Text(t) => {
4731 std::str::from_utf8(t)
4732 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
4733 match column.ty {
4734 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
4735 Ok(ToSqlOutput::Borrowed(value))
4736 }
4737 _ => mismatch(),
4738 }
4739 }
4740 ValueRef::Blob(_) => match column.ty {
4741 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
4743 _ => mismatch(),
4744 },
4745 }
4746}
4747
4748pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
4749 use rusqlite::types::ValueRef;
4750 match value {
4751 ValueRef::Null => Value::Null,
4752 ValueRef::Integer(i) => match column.ty {
4753 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
4754 ssp2::segment::ColumnType::Float => {
4755 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
4756 }
4757 _ => Value::from(i),
4758 },
4759 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
4760 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
4761 ValueRef::Blob(b) => {
4762 let mut map = Map::new();
4763 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
4764 Value::Object(map)
4765 }
4766 }
4767}
4768
4769fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
4773 Ok(match value {
4774 Value::Null => SqlValue::Null,
4775 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
4776 Value::Number(n) => {
4777 if let Some(i) = n.as_i64() {
4778 SqlValue::Integer(i)
4779 } else if let Some(f) = n.as_f64() {
4780 SqlValue::Real(f)
4781 } else {
4782 return Err(format!("query param number {n} is out of range"));
4783 }
4784 }
4785 Value::String(s) => SqlValue::Text(s.clone()),
4786 Value::Object(_) => {
4787 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
4788 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
4789 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
4790 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
4791 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
4792 })?)
4793 } else {
4794 return Err(
4795 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
4796 .to_owned(),
4797 );
4798 }
4799 }
4800 Value::Array(_) => return Err("query array params are not supported".to_owned()),
4801 })
4802}
4803
4804fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
4809 use rusqlite::types::ValueRef;
4810 match value {
4811 ValueRef::Null => Value::Null,
4812 ValueRef::Integer(i) => {
4813 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
4817 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
4818 Value::from(i)
4819 } else {
4820 let mut map = Map::new();
4821 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
4822 Value::Object(map)
4823 }
4824 }
4825 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
4826 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
4827 ValueRef::Blob(b) => {
4828 let mut map = Map::new();
4829 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
4830 Value::Object(map)
4831 }
4832 }
4833}
4834
4835fn query_connection(
4836 conn: &Connection,
4837 sql: &str,
4838 params: &[QueryValue],
4839) -> Result<Vec<QueryRow>, String> {
4840 crate::query_guard::assert_read_only_query(sql)?;
4841 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
4842 let bound: Vec<SqlValue> = params
4843 .iter()
4844 .map(json_param_to_sql)
4845 .collect::<Result<_, _>>()?;
4846 let mut stmt = conn.prepare(&lowered_sql).map_err(|e| e.to_string())?;
4847 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
4848 let bound_refs: Vec<&dyn rusqlite::ToSql> =
4849 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
4850 let mut sql_rows = stmt
4851 .query(bound_refs.as_slice())
4852 .map_err(|e| e.to_string())?;
4853 let mut out = Vec::new();
4854 while let Some(row) = sql_rows.next().map_err(|e| e.to_string())? {
4855 let mut record = Map::new();
4856 for (i, name) in column_names.iter().enumerate() {
4857 let value = row.get_ref(i).map_err(|e| e.to_string())?;
4858 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
4859 }
4860 out.push(record);
4861 }
4862 Ok(out)
4863}
4864
4865fn persisted_window_state(conn: &Connection, base: &WindowBase) -> Result<WindowState, String> {
4866 let mut stmt = conn
4867 .prepare(
4868 "SELECT windows.unit, subscriptions.state_json
4869 FROM _syncular_windows AS windows
4870 JOIN _syncular_subscriptions AS subscriptions
4871 ON subscriptions.id = windows.sub_id
4872 WHERE windows.base = ?1
4873 ORDER BY windows.unit ASC",
4874 )
4875 .map_err(|error| error.to_string())?;
4876 let rows = stmt
4877 .query_map(rusqlite::params![window_base_key(base)], |row| {
4878 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
4879 })
4880 .map_err(|error| error.to_string())?;
4881 let mut units = Vec::new();
4882 let mut pending = Vec::new();
4883 for row in rows {
4884 let (unit, raw) = row.map_err(|error| error.to_string())?;
4885 let state: Value = serde_json::from_str(&raw)
4886 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
4887 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
4888 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
4889 || state
4890 .get("bootstrapState")
4891 .is_some_and(|value| !value.is_null());
4892 if is_pending {
4893 pending.push(unit.clone());
4894 }
4895 units.push(unit);
4896 }
4897 Ok(WindowState { units, pending })
4898}
4899
4900fn snapshot_connection(
4901 conn: &Connection,
4902 sql: &str,
4903 params: &[Value],
4904 coverage: &[WindowCoverage],
4905) -> Result<QuerySnapshot, String> {
4906 conn.execute_batch("SAVEPOINT syncular_snapshot_read")
4907 .map_err(|error| error.to_string())?;
4908 let result = (|| {
4909 let revision = conn
4910 .query_row(
4911 "SELECT value FROM _syncular_meta WHERE key = ?1",
4912 rusqlite::params![LOCAL_REVISION_KEY],
4913 |row| row.get::<_, String>(0),
4914 )
4915 .ok()
4916 .and_then(|value| value.parse::<u64>().ok())
4917 .unwrap_or(0);
4918 let rows = query_connection(conn, sql, params)?;
4919 let mut pending = Vec::new();
4920 let mut missing = Vec::new();
4921 for requested in coverage {
4922 let base_key = window_base_key(&requested.base);
4923 let state = persisted_window_state(conn, &requested.base)?;
4924 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
4925 let reference = WindowUnitRef {
4926 base_key: base_key.clone(),
4927 unit: unit.clone(),
4928 };
4929 if !state.units.iter().any(|held| held == &unit) {
4930 missing.push(reference);
4931 } else if state.pending.iter().any(|held| held == &unit) {
4932 pending.push(reference);
4933 }
4934 }
4935 }
4936 Ok(QuerySnapshot {
4937 revision: revision.to_string(),
4938 rows,
4939 coverage: CoverageSnapshot {
4940 complete: pending.is_empty() && missing.is_empty(),
4941 pending,
4942 missing,
4943 },
4944 })
4945 })();
4946 match result {
4947 Ok(snapshot) => {
4948 conn.execute_batch("RELEASE syncular_snapshot_read")
4949 .map_err(|error| error.to_string())?;
4950 Ok(snapshot)
4951 }
4952 Err(error) => {
4953 let _ = conn.execute_batch(
4954 "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
4955 );
4956 Err(error)
4957 }
4958 }
4959}
4960
4961pub struct FileQuerySnapshotReader {
4966 path: String,
4967 conn: Option<Connection>,
4968}
4969
4970impl FileQuerySnapshotReader {
4971 #[must_use]
4972 pub fn new(path: impl Into<String>) -> Self {
4973 Self {
4974 path: path.into(),
4975 conn: None,
4976 }
4977 }
4978
4979 fn connection(&mut self) -> Result<&Connection, String> {
4980 if self.conn.is_none() {
4981 let conn = Connection::open_with_flags(
4982 &self.path,
4983 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
4984 )
4985 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
4986 conn.busy_timeout(std::time::Duration::from_millis(250))
4987 .map_err(|error| error.to_string())?;
4988 self.conn = Some(conn);
4989 }
4990 self.conn
4991 .as_ref()
4992 .ok_or_else(|| "read sidecar connection missing".to_owned())
4993 }
4994
4995 pub fn query_snapshot(
4996 &mut self,
4997 sql: &str,
4998 params: &[Value],
4999 coverage: &[WindowCoverage],
5000 ) -> Result<QuerySnapshot, String> {
5001 snapshot_connection(self.connection()?, sql, params, coverage)
5002 }
5003}
5004
5005impl SyncClient {
5006 pub fn new_with_identity(
5007 client_id: Option<String>,
5008 schema_json: &Value,
5009 limits: ClientLimits,
5010 ) -> Result<Self, String> {
5011 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5012 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5013 Self::with_connection(resolved, schema_json, limits, conn)
5014 }
5015
5016 pub fn new(
5017 client_id: String,
5018 schema_json: &Value,
5019 limits: ClientLimits,
5020 ) -> Result<Self, String> {
5021 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5022 Self::with_connection(client_id, schema_json, limits, conn)
5023 }
5024
5025 pub fn open_path(
5031 client_id: String,
5032 schema_json: &Value,
5033 limits: ClientLimits,
5034 path: &str,
5035 ) -> Result<Self, String> {
5036 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5037 Self::with_connection(client_id, schema_json, limits, conn)
5038 }
5039
5040 pub fn open_path_with_identity(
5041 client_id: Option<String>,
5042 schema_json: &Value,
5043 limits: ClientLimits,
5044 path: &str,
5045 ) -> Result<Self, String> {
5046 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5047 conn.busy_timeout(std::time::Duration::from_millis(250))
5053 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5054 conn.pragma_update(None, "journal_mode", "WAL")
5055 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5056 let persisted = conn
5057 .query_row(
5058 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5059 [],
5060 |row| row.get::<_, String>(0),
5061 )
5062 .ok();
5063 let resolved = persisted
5064 .clone()
5065 .or(client_id.clone())
5066 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5067 if let (Some(existing), Some(requested)) = (persisted, client_id) {
5068 if existing != requested {
5069 return Err(format!(
5070 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5071 ));
5072 }
5073 }
5074 Self::with_connection(resolved, schema_json, limits, conn)
5075 }
5076
5077 #[must_use]
5078 pub fn client_id(&self) -> &str {
5079 &self.client_id
5080 }
5081
5082 pub fn with_connection(
5089 client_id: String,
5090 schema_json: &Value,
5091 limits: ClientLimits,
5092 conn: Connection,
5093 ) -> Result<Self, String> {
5094 if limits.outcome_retention_max_entries == Some(0) {
5095 return Err(
5096 "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5097 );
5098 }
5099 let schema = parse_schema_json(schema_json)?;
5100 let previous_version = match limits.previous_version_context {
5103 Some(config) => {
5104 config.validate()?;
5105 Some(config)
5106 }
5107 None => None,
5108 };
5109 let mut client = SyncClient {
5110 #[cfg(feature = "bench-internals")]
5111 benchmark_phases: Recorder::default(),
5112 conn,
5113 schema,
5114 client_id,
5115 limits,
5116 subs: Vec::new(),
5117 outbox: Vec::new(),
5118 conflicts: Vec::new(),
5119 rejections: Vec::new(),
5120 schema_floor: None,
5121 lease_state: None,
5122 stopped: false,
5123 upgrading: false,
5124 sync_needed: false,
5125 realtime_connected: false,
5126 presence: HashMap::new(),
5127 now_ms: None,
5128 encryption: crate::values::EncryptionConfig::default(),
5129 security_preflight: false,
5130 previous_version,
5131 insert_sql: RefCell::new(HashMap::new()),
5132 overlay_dirty: Cell::new(false),
5133 #[cfg(test)]
5134 overlay_rebuild_count: Cell::new(0),
5135 #[cfg(test)]
5136 outcome_prune_count: Cell::new(0),
5137 progress: ProgressObserver::default(),
5138 change_queue: VecDeque::new(),
5139 sync_intent_queue: VecDeque::new(),
5140 retry_delay_ms: 250,
5141 last_round: None,
5142 last_change: None,
5143 };
5144 client
5148 .conn
5149 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5150 client.create_bookkeeping_tables()?;
5154 match client.get_meta(CLIENT_ID_KEY) {
5155 Some(existing) if existing != client.client_id => {
5156 return Err(format!(
5157 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5158 client.client_id
5159 ));
5160 }
5161 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5162 _ => {}
5163 }
5164 client.restore_persisted_state()?;
5165 let marker = client
5166 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5167 .and_then(|value| value.parse::<i32>().ok());
5168 match marker {
5169 None => {
5170 client.create_synced_tables()?;
5171 client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5172 set_local_schema_descriptor(&client.conn, &client.schema);
5174 }
5175 Some(version) if version == client.schema.version => {
5176 client.create_synced_tables()?;
5177 set_local_schema_descriptor(&client.conn, &client.schema);
5181 }
5182 Some(_) => client.run_schema_reset()?,
5183 }
5184 client.reconcile_previous_version_at_boot()?;
5190 client.clear_satisfied_persisted_schema_floor();
5191 client.prune_unknown_subscriptions()?;
5192 if marker == Some(client.schema.version) && !client.outbox.is_empty() {
5193 client.overlay_dirty.set(true);
5196 client.rebuild_overlay();
5197 }
5198 client.enqueue_startup_sync_if_needed()?;
5204 Ok(client)
5205 }
5206
5207 pub fn set_now_ms(&mut self, now_ms: i64) {
5210 self.now_ms = Some(now_ms);
5211 }
5212
5213 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
5217 self.encryption = encryption;
5218 }
5219
5220 #[must_use]
5221 pub fn security_lifecycle(&self) -> &'static str {
5222 if self.security_preflight {
5223 "preflight"
5224 } else {
5225 "active"
5226 }
5227 }
5228
5229 #[must_use]
5230 pub fn security_preflight(&self) -> bool {
5231 self.security_preflight
5232 }
5233
5234 pub fn begin_security_preflight(&mut self) {
5241 self.seal_security_on_teardown();
5242 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
5245 }
5246
5247 pub fn seal_security_on_teardown(&mut self) {
5257 self.security_preflight = true;
5258 self.encryption = crate::values::EncryptionConfig::default();
5259 self.sync_intent_queue.clear();
5260 }
5261
5262 pub fn activate_security(
5264 &mut self,
5265 encryption: crate::values::EncryptionConfig,
5266 ) -> Result<(), String> {
5267 if !self.security_preflight {
5268 return Err(
5269 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
5270 );
5271 }
5272 self.enqueue_startup_sync_if_needed()?;
5273 self.encryption = encryption;
5274 self.security_preflight = false;
5275 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
5276 Ok(())
5277 }
5278
5279 fn clock_now_ms(&self) -> i64 {
5280 self.now_ms.unwrap_or_else(|| {
5281 std::time::SystemTime::now()
5282 .duration_since(std::time::UNIX_EPOCH)
5283 .map(|d| d.as_millis() as i64)
5284 .unwrap_or(0)
5285 })
5286 }
5287
5288 fn create_bookkeeping_tables(&self) -> Result<(), String> {
5289 self.conn
5291 .execute_batch(
5292 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
5293 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5294 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
5295 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
5296 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5297 client_commit_id TEXT NOT NULL UNIQUE,
5298 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
5299 recorded_at_ms INTEGER NOT NULL,
5300 results_json TEXT NOT NULL,
5301 operations_json TEXT,
5302 resolution TEXT NOT NULL DEFAULT 'active'
5303 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
5304 resolved_at_ms INTEGER,
5305 replacement_client_commit_id TEXT);
5306 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
5307 ON _syncular_commit_outcomes(resolution, seq);
5308 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
5309 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
5310 CREATE TABLE IF NOT EXISTS _syncular_meta (
5311 key TEXT PRIMARY KEY, value TEXT NOT NULL);
5312 CREATE TABLE IF NOT EXISTS _syncular_windows (
5313 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
5314 PRIMARY KEY (base, unit));
5315 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
5316 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
5317 effective_scopes TEXT NOT NULL);
5318 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
5319 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
5320 PRIMARY KEY(commit_id, blob_id));
5321 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
5322 ON _syncular_blob_commit_refs(blob_id);",
5323 )
5324 .map_err(|e| e.to_string())?;
5325 let _ = self
5328 .conn
5329 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
5330 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
5331 self.set_meta(LOCAL_REVISION_KEY, "0");
5332 }
5333 if self.schema_has_blobs() {
5336 let exists = self
5337 .conn
5338 .query_row(
5339 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5340 [],
5341 |row| row.get::<_, bool>(0),
5342 )
5343 .map_err(|e| e.to_string())?;
5344 if exists {
5345 let mut statement = self
5346 .conn
5347 .prepare("PRAGMA table_info(_syncular_blobs)")
5348 .map_err(|e| e.to_string())?;
5349 let mut columns = statement
5350 .query_map([], |row| row.get::<_, String>(1))
5351 .map_err(|e| e.to_string())?
5352 .collect::<Result<Vec<_>, _>>()
5353 .map_err(|e| e.to_string())?;
5354 columns.sort();
5355 let mut expected = vec![
5356 "blob_id",
5357 "byte_length",
5358 "bytes",
5359 "created_at_ms",
5360 "media_type",
5361 ];
5362 expected.sort_unstable();
5363 if columns != expected {
5364 return Err(
5365 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5366 );
5367 }
5368 } else {
5369 self.conn
5370 .execute_batch(
5371 "CREATE TABLE _syncular_blobs(
5372 blob_id TEXT PRIMARY KEY,
5373 bytes BLOB NOT NULL,
5374 byte_length INTEGER NOT NULL,
5375 media_type TEXT,
5376 created_at_ms INTEGER NOT NULL);",
5377 )
5378 .map_err(|e| e.to_string())?;
5379 }
5380 self.conn
5381 .execute_batch(
5382 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5383 blob_id TEXT PRIMARY KEY,
5384 media_type TEXT,
5385 created_at_ms INTEGER NOT NULL);",
5386 )
5387 .map_err(|e| e.to_string())?;
5388 }
5389 Ok(())
5390 }
5391
5392 fn schema_has_blobs(&self) -> bool {
5394 self.schema
5395 .tables
5396 .iter()
5397 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5398 }
5399
5400 fn get_meta(&self, key: &str) -> Option<String> {
5403 meta_get(&self.conn, key)
5404 }
5405
5406 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5407 self.conn
5408 .query_row(
5409 "SELECT value FROM _syncular_meta WHERE key = ?1",
5410 rusqlite::params![key],
5411 |row| row.get::<_, String>(0),
5412 )
5413 .optional()
5414 .map_err(|_| {
5415 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5416 })
5417 }
5418
5419 fn set_meta(&self, key: &str, value: &str) {
5420 meta_set(&self.conn, key, value);
5421 }
5422
5423 fn delete_meta(&self, key: &str) {
5424 meta_delete(&self.conn, key);
5425 }
5426
5427 fn restore_persisted_state(&mut self) -> Result<(), String> {
5428 self.subs = {
5429 let mut stmt = self
5430 .conn
5431 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5432 .map_err(|error| error.to_string())?;
5433 let rows = stmt
5434 .query_map([], |row| {
5435 Ok((
5436 row.get::<_, String>(0)?,
5437 row.get::<_, String>(1)?,
5438 row.get::<_, String>(2)?,
5439 ))
5440 })
5441 .map_err(|error| error.to_string())?;
5442 let mut subscriptions = Vec::new();
5443 for row in rows {
5444 let (id, table, raw) = row.map_err(|error| error.to_string())?;
5445 let state: Value = serde_json::from_str(&raw)
5446 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5447 let requested = json_to_scope_map(
5448 state.get("requested").unwrap_or(&Value::Object(Map::new())),
5449 )?;
5450 let effective = state
5451 .get("effectiveScopes")
5452 .filter(|value| !value.is_null())
5453 .map(json_to_scope_map)
5454 .transpose()?;
5455 subscriptions.push(Subscription {
5456 id,
5457 table,
5458 requested,
5459 params: state
5460 .get("params")
5461 .and_then(Value::as_str)
5462 .map(str::to_owned),
5463 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
5464 bootstrap_state: state
5465 .get("bootstrapState")
5466 .and_then(Value::as_str)
5467 .map(str::to_owned),
5468 state: SubState::parse(
5469 state
5470 .get("status")
5471 .and_then(Value::as_str)
5472 .unwrap_or("active"),
5473 ),
5474 reason_code: state
5475 .get("reasonCode")
5476 .and_then(Value::as_str)
5477 .map(str::to_owned),
5478 effective,
5479 synced_once: state
5480 .get("syncedOnce")
5481 .and_then(Value::as_bool)
5482 .unwrap_or(false),
5483 });
5484 }
5485 subscriptions
5486 };
5487
5488 self.outbox = {
5489 let mut stmt = self
5490 .conn
5491 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
5492 .map_err(|error| error.to_string())?;
5493 let rows = stmt
5494 .query_map([], |row| {
5495 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5496 })
5497 .map_err(|error| error.to_string())?;
5498 let mut commits = Vec::new();
5499 for row in rows {
5500 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
5501 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
5502 format!("invalid persisted outbox {client_commit_id:?}: {error}")
5503 })?;
5504 let mut ops = Vec::with_capacity(entries.len());
5505 for entry in entries {
5506 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
5507 ops.push(OutboxOp {
5508 upsert: op == "upsert",
5509 table: entry
5510 .get("table")
5511 .and_then(Value::as_str)
5512 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
5513 .to_owned(),
5514 row_id: entry
5515 .get("rowId")
5516 .and_then(Value::as_str)
5517 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
5518 .to_owned(),
5519 base_version: entry.get("baseVersion").and_then(Value::as_i64),
5520 values: entry.get("values").and_then(Value::as_object).cloned(),
5523 });
5524 }
5525 commits.push(OutboxCommit {
5526 client_commit_id,
5527 ops,
5528 });
5529 }
5530 commits
5531 };
5532
5533 self.prune_commit_outcomes()?;
5534 let active = self.commit_outcomes(CommitOutcomeQuery {
5535 active_only: true,
5536 ..CommitOutcomeQuery::default()
5537 })?;
5538 self.conflicts = active
5539 .iter()
5540 .flat_map(|outcome| outcome.results.iter())
5541 .filter_map(|result| match result {
5542 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
5543 _ => None,
5544 })
5545 .collect();
5546 self.rejections = active
5547 .iter()
5548 .flat_map(|outcome| outcome.results.iter())
5549 .filter_map(|result| match result {
5550 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
5551 _ => None,
5552 })
5553 .collect();
5554
5555 self.lease_state = self
5556 .get_meta(LEASE_STATE_KEY)
5557 .map(|raw| serde_json::from_str(&raw))
5558 .transpose()
5559 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
5560 self.schema_floor = self
5561 .get_meta(SCHEMA_FLOOR_KEY)
5562 .map(|raw| serde_json::from_str(&raw))
5563 .transpose()
5564 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
5565 self.stopped = self.schema_floor.is_some();
5566 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
5569 self.security_preflight = true;
5570 }
5571 Ok(())
5572 }
5573
5574 fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
5577 let valid_tables: BTreeSet<String> = self
5578 .schema
5579 .tables
5580 .iter()
5581 .map(|table| table.name.clone())
5582 .collect();
5583 let stale_ids: Vec<String> = self
5584 .subs
5585 .iter()
5586 .filter(|sub| !valid_tables.contains(&sub.table))
5587 .map(|sub| sub.id.clone())
5588 .collect();
5589 for id in &stale_ids {
5590 self.conn
5591 .execute(
5592 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
5593 rusqlite::params![id],
5594 )
5595 .map_err(|error| error.to_string())?;
5596 self.conn
5597 .execute(
5598 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
5599 rusqlite::params![id],
5600 )
5601 .map_err(|error| error.to_string())?;
5602 self.conn
5603 .execute(
5604 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
5605 rusqlite::params![id],
5606 )
5607 .map_err(|error| error.to_string())?;
5608 }
5609 self.subs.retain(|sub| valid_tables.contains(&sub.table));
5610 Ok(())
5611 }
5612
5613 #[must_use]
5614 pub fn local_revision(&self) -> u64 {
5615 self.get_meta(LOCAL_REVISION_KEY)
5616 .and_then(|value| value.parse().ok())
5617 .unwrap_or(0)
5618 }
5619
5620 #[must_use]
5621 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
5622 SyncStatusSnapshot {
5623 current_schema_version: self.schema.version,
5624 outbox: self.outbox.len(),
5625 upgrading: self.upgrading,
5626 lease_state: self.lease_state.clone(),
5627 schema_floor: self.schema_floor.clone(),
5628 sync_needed: self.sync_needed,
5629 previous_version_context: self.previous_version_status(),
5630 }
5631 }
5632
5633 fn previous_version_status(&self) -> PreviousVersionStatus {
5636 let Some(replica_path) = self.previous_version_replica_path() else {
5637 return PreviousVersionStatus {
5638 present: false,
5639 created_at_ms: None,
5640 };
5641 };
5642 match crate::previous_version::read_previous_version_record(&replica_path) {
5643 Ok(Some(record)) => PreviousVersionStatus {
5644 present: true,
5645 created_at_ms: Some(record.created_at_ms),
5646 },
5647 _ => PreviousVersionStatus {
5648 present: std::path::Path::new(
5649 &crate::previous_version::previous_version_container_path(&replica_path),
5650 )
5651 .exists(),
5652 created_at_ms: None,
5653 },
5654 }
5655 }
5656
5657 pub fn previous_version_snapshot(
5663 &self,
5664 spec: &PreviousVersionReadSpec,
5665 ) -> Result<PreviousVersionSnapshot, String> {
5666 let now_ms = self.clock_now_ms();
5667 crate::previous_version::previous_version_snapshot(
5668 &self.conn,
5669 self.previous_version_replica_path().as_deref(),
5670 self.previous_version.as_ref(),
5671 PreviousVersionLifecycle {
5672 lease_inactive: self.previous_version_lease_inactive(now_ms),
5673 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
5674 coverage_complete: self.previous_version_coverage_complete(),
5675 },
5676 now_ms,
5677 self.schema.version,
5678 spec,
5679 )
5680 }
5681
5682 #[must_use]
5684 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
5685 crate::previous_version::stored_previous_version_audit(&self.conn)
5686 }
5687
5688 pub fn previous_version_discard(
5692 &self,
5693 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
5694 self.drop_previous_version()
5695 }
5696
5697 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
5701 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
5702 present: false,
5703 discarded: false,
5704 };
5705 let Some(replica_path) = self.previous_version_replica_path() else {
5706 return absent;
5707 };
5708 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
5709 .unwrap_or(absent)
5710 }
5711
5712 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
5714 let Some(lease) = &self.lease_state else {
5715 return false;
5716 };
5717 if lease.error_code.is_some() {
5718 return true;
5719 }
5720 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
5721 }
5722
5723 fn previous_version_coverage_complete(&self) -> bool {
5727 let active = self
5728 .subs
5729 .iter()
5730 .filter(|sub| sub.state == SubState::Active)
5731 .collect::<Vec<_>>();
5732 if active.is_empty() {
5733 return false;
5734 }
5735 active
5736 .iter()
5737 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
5738 }
5739
5740 fn previous_version_lifetime_check(&self) {
5744 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
5745 return;
5746 };
5747 let now_ms = self.clock_now_ms();
5748 if self.previous_version_lease_inactive(now_ms) {
5749 let _ = self.drop_previous_version();
5750 return;
5751 }
5752 let Some(replica_path) = self.previous_version_replica_path() else {
5753 return;
5754 };
5755 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
5756 else {
5757 return;
5758 };
5759 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
5760 let _ = self.drop_previous_version();
5761 return;
5762 }
5763 if now_ms - record.created_at_ms > config.max_age_ms {
5764 let _ = self.drop_previous_version();
5765 return;
5766 }
5767 if self.previous_version_coverage_complete() {
5768 let _ = self.drop_previous_version();
5769 }
5770 }
5771
5772 pub fn diagnostics_snapshot(
5773 &self,
5774 request: &ClientDiagnosticsRequest,
5775 ) -> Result<ClientDiagnosticsSnapshot, String> {
5776 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
5777 return Err(format!(
5778 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
5779 ));
5780 }
5781 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
5782 for sub in &self.subs {
5783 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
5784 let complete =
5785 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
5786 let state = match sub.state {
5787 SubState::Revoked => "revoked",
5788 SubState::Failed => "failed",
5789 SubState::Active if reset => "reset",
5790 SubState::Active if complete => "complete",
5791 SubState::Active => "bootstrapping",
5792 };
5793 subscriptions.insert(
5794 sub.id.clone(),
5795 DiagnosticSubscription {
5796 id: sub.id.clone(),
5797 table: sub.table.clone(),
5798 state: state.to_owned(),
5799 complete,
5800 cursor: Some(sub.cursor),
5801 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
5802 },
5803 );
5804 }
5805 for expected in &request.expected_subscriptions {
5806 if expected.id.is_empty() || expected.table.is_empty() {
5807 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
5808 }
5809 if subscriptions
5810 .get(&expected.id)
5811 .is_some_and(|registered| registered.table != expected.table)
5812 {
5813 subscriptions.insert(
5814 expected.id.clone(),
5815 DiagnosticSubscription {
5816 id: expected.id.clone(),
5817 table: expected.table.clone(),
5818 state: "failed".to_owned(),
5819 complete: false,
5820 cursor: None,
5821 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
5822 },
5823 );
5824 } else {
5825 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
5826 DiagnosticSubscription {
5827 id: expected.id.clone(),
5828 table: expected.table.clone(),
5829 state: "unregistered".to_owned(),
5830 complete: false,
5831 cursor: None,
5832 reason_code: None,
5833 }
5834 });
5835 }
5836 }
5837 let mut ordered_subscriptions = Vec::new();
5838 let mut included = BTreeSet::new();
5839 for expected in &request.expected_subscriptions {
5840 if included.insert(expected.id.clone()) {
5841 if let Some(subscription) = subscriptions.get(&expected.id) {
5842 ordered_subscriptions.push(subscription.clone());
5843 }
5844 }
5845 }
5846 for (id, subscription) in &subscriptions {
5847 if included.insert(id.clone()) {
5848 ordered_subscriptions.push(subscription.clone());
5849 }
5850 }
5851 let subscriptions_truncated =
5852 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
5853 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
5854 let captured_at_ms = self.clock_now_ms();
5855 let lease = if let Some(error_code) = self
5856 .lease_state
5857 .as_ref()
5858 .and_then(|state| state.error_code.clone())
5859 {
5860 ClientDiagnosticsLease {
5861 state: "stopped".to_owned(),
5862 expires_at_ms: self
5863 .lease_state
5864 .as_ref()
5865 .and_then(|state| state.expires_at_ms),
5866 error_code: Some(Self::diagnostic_code(&error_code)),
5867 }
5868 } else if let Some(expires_at_ms) = self
5869 .lease_state
5870 .as_ref()
5871 .and_then(|state| state.expires_at_ms)
5872 {
5873 ClientDiagnosticsLease {
5874 state: if expires_at_ms <= captured_at_ms {
5875 "expired".to_owned()
5876 } else {
5877 "active".to_owned()
5878 },
5879 expires_at_ms: Some(expires_at_ms),
5880 error_code: None,
5881 }
5882 } else {
5883 ClientDiagnosticsLease {
5884 state: "none".to_owned(),
5885 expires_at_ms: None,
5886 error_code: None,
5887 }
5888 };
5889 let connectivity = match self.last_round.as_ref() {
5890 Some(round) if round.status == "succeeded" => "online",
5891 Some(round)
5892 if round.status == "failed"
5893 && round
5894 .error_code
5895 .as_deref()
5896 .is_some_and(Self::retryable_transport_code) =>
5897 {
5898 "offline"
5899 }
5900 _ => "unknown",
5901 };
5902 Ok(ClientDiagnosticsSnapshot {
5903 version: CLIENT_DIAGNOSTICS_VERSION,
5904 captured_at_ms,
5905 host: ClientDiagnosticsHost {
5906 kind: "direct".to_owned(),
5907 role: "single".to_owned(),
5908 connectivity: connectivity.to_owned(),
5909 realtime: if self.realtime_connected {
5910 "connected".to_owned()
5911 } else {
5912 "disconnected".to_owned()
5913 },
5914 },
5915 security_lifecycle: self.security_lifecycle().to_owned(),
5916 schema: ClientDiagnosticsSchema {
5917 current_version: self.schema.version,
5918 upgrading: self.upgrading,
5919 required_version: self
5920 .schema_floor
5921 .as_ref()
5922 .and_then(|floor| floor.required_schema_version),
5923 latest_version: self
5924 .schema_floor
5925 .as_ref()
5926 .and_then(|floor| floor.latest_schema_version),
5927 },
5928 replica: ClientDiagnosticsReplica {
5929 local_revision: self.local_revision().to_string(),
5930 sync_needed: self.sync_needed,
5931 pending_outbox: self.outbox.len(),
5932 },
5933 lease,
5934 subscriptions: ordered_subscriptions,
5935 subscriptions_truncated,
5936 last_round: self.last_round.clone(),
5937 last_change: self.last_change.clone(),
5938 storage: self.diagnostics_storage(),
5939 })
5940 }
5941
5942 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
5943 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
5944 let page_count: i64 = self
5945 .conn
5946 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
5947 let page_size: i64 = self
5948 .conn
5949 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
5950 let outbox_bytes: i64 = self
5952 .conn
5953 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
5954 .query_row([], |row| row.get(0))?;
5955 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
5956 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
5957 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
5958 let blob_bytes = if self.schema_has_blobs() {
5959 self.conn
5960 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
5961 .query_row([], |row| row.get(0))?
5962 } else {
5963 0
5964 };
5965 let pressure = self
5966 .limits
5967 .blob_cache_max_bytes
5968 .is_some_and(|limit| blob_bytes > limit);
5969 Ok(ClientDiagnosticsStorage {
5970 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
5971 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
5972 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
5973 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
5974 retained_outcome_entries: Some(outcome_entries.max(0)),
5975 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
5976 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
5977 })
5978 };
5979 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
5980 status: "unreadable".to_owned(),
5981 database_bytes_approx: None,
5982 pending_outbox_bytes_approx: None,
5983 retained_outcome_bytes_approx: None,
5984 retained_outcome_entries: None,
5985 blob_cache_bytes_approx: None,
5986 pressure_reason_code: None,
5987 })
5988 }
5989
5990 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
5991 self.change_queue.drain(..).collect()
5992 }
5993
5994 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
5995 self.sync_intent_queue.drain(..).collect()
5996 }
5997
5998 fn schedule_background_retry(&mut self) {
5999 self.sync_intent_queue.push_back(SyncIntent::Background {
6000 delay_ms: self.retry_delay_ms,
6001 });
6002 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
6003 }
6004
6005 fn reset_background_retry(&mut self) {
6006 self.retry_delay_ms = 250;
6007 }
6008
6009 fn retryable_transport_code(code: &str) -> bool {
6010 code == "transport.failed"
6011 || code == "transport.unavailable"
6012 || code == "sync.transport_failed"
6013 }
6014
6015 fn diagnostic_code(code: &str) -> String {
6016 let valid = !code.is_empty()
6017 && code.len() <= 96
6018 && code.contains('.')
6019 && code
6020 .bytes()
6021 .next()
6022 .is_some_and(|byte| byte.is_ascii_lowercase())
6023 && code.bytes().all(|byte| {
6024 byte.is_ascii_lowercase()
6025 || byte.is_ascii_digit()
6026 || matches!(byte, b'.' | b'_' | b'-')
6027 });
6028 if valid {
6029 code.to_owned()
6030 } else {
6031 "client.unknown_failure".to_owned()
6032 }
6033 }
6034
6035 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
6036 if self.sync_needed != value {
6037 if self.begin_observation("syncular_status").is_ok() {
6038 self.sync_needed = value;
6039 let batch = ChangeAccumulator {
6040 status: true,
6041 ..ChangeAccumulator::default()
6042 };
6043 if self.finish_observation("syncular_status", batch).is_err() {
6044 self.rollback_observation("syncular_status");
6045 }
6046 } else {
6047 self.sync_needed = value;
6048 }
6049 }
6050 if value && interactive {
6051 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6052 }
6053 }
6054
6055 fn begin_observation(&self, name: &str) -> Result<(), String> {
6056 self.conn
6057 .execute_batch(&format!("SAVEPOINT {name}"))
6058 .map_err(|error| error.to_string())
6059 }
6060
6061 fn rollback_observation(&self, name: &str) {
6062 let _ = self
6063 .conn
6064 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
6065 }
6066
6067 fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
6068 #[cfg(feature = "bench-internals")]
6069 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
6070 if !batch.touched() {
6071 self.conn
6072 .execute_batch(&format!("RELEASE {name}"))
6073 .map_err(|error| error.to_string())?;
6074 return Ok(());
6075 }
6076 let revision = self
6077 .local_revision()
6078 .checked_add(1)
6079 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
6080 self.conn
6081 .execute(
6082 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
6083 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
6084 )
6085 .map_err(|error| error.to_string())?;
6086 let status = batch.status.then(|| self.status_snapshot());
6087 let event = ClientChangeBatch {
6088 revision: revision.to_string(),
6089 tables: batch
6090 .tables
6091 .into_iter()
6092 .map(|(table, scope_keys)| TableChange {
6093 table,
6094 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
6095 })
6096 .collect(),
6097 windows: batch
6098 .windows
6099 .into_iter()
6100 .map(|((base_key, table), units)| WindowChange {
6101 base_key,
6102 table,
6103 units: units.into_iter().collect(),
6104 })
6105 .collect(),
6106 status,
6107 conflicts_changed: batch.conflicts,
6108 rejections_changed: batch.rejections,
6109 outcomes_changed: batch.outcomes,
6110 };
6111 self.conn
6112 .execute_batch(&format!("RELEASE {name}"))
6113 .map_err(|error| error.to_string())?;
6114 let mut diagnostic_tables = event
6115 .tables
6116 .iter()
6117 .map(|entry| entry.table.clone())
6118 .collect::<BTreeSet<_>>()
6119 .into_iter()
6120 .collect::<Vec<_>>();
6121 let mut diagnostic_windows = event
6122 .windows
6123 .iter()
6124 .map(|entry| entry.table.clone())
6125 .collect::<BTreeSet<_>>()
6126 .into_iter()
6127 .collect::<Vec<_>>();
6128 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
6129 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
6130 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
6131 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
6132 self.last_change = Some(DiagnosticLastChange {
6133 revision: event.revision.clone(),
6134 recorded_at_ms: self.clock_now_ms(),
6135 tables: diagnostic_tables,
6136 windows: diagnostic_windows,
6137 domains_truncated,
6138 status_changed: event.status.is_some(),
6139 conflicts_changed: event.conflicts_changed,
6140 rejections_changed: event.rejections_changed,
6141 outcomes_changed: event.outcomes_changed,
6142 });
6143 self.change_queue.push_back(event);
6144 Ok(())
6145 }
6146
6147 fn record_scope_map(
6148 &self,
6149 batch: &mut ChangeAccumulator,
6150 table_name: &str,
6151 scopes: &[(String, Vec<String>)],
6152 ) {
6153 let Some(table) = self.schema.table(table_name) else {
6154 return;
6155 };
6156 for (variable, values) in scopes {
6157 let Some(scope) = table
6158 .scope_variables
6159 .iter()
6160 .find(|scope| &scope.variable == variable)
6161 else {
6162 continue;
6163 };
6164 for value in values {
6165 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6166 }
6167 }
6168 }
6169
6170 fn record_row_scopes(
6172 &self,
6173 batch: &mut ChangeAccumulator,
6174 table_name: &str,
6175 row_id: &str,
6176 base: bool,
6177 ) -> bool {
6178 let Some(table) = self.schema.table(table_name) else {
6179 return false;
6180 };
6181 if table.scope_variables.is_empty() {
6182 return false;
6183 }
6184 let columns = table
6185 .scope_variables
6186 .iter()
6187 .map(|scope| quote_ident(&scope.column))
6188 .collect::<Vec<_>>()
6189 .join(", ");
6190 let full_table = if base {
6191 base_table(table_name)
6192 } else {
6193 visible_table(table_name)
6194 };
6195 let sql = format!(
6196 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
6197 row_id_predicate(table)
6198 );
6199 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
6200 return false;
6201 };
6202 let values = stmt.query_row(rusqlite::params![row_id], |row| {
6203 let mut values = Vec::with_capacity(table.scope_variables.len());
6204 for index in 0..table.scope_variables.len() {
6205 values.push(row.get::<_, Option<String>>(index)?);
6206 }
6207 Ok(values)
6208 });
6209 let Ok(values) = values else {
6210 return false;
6211 };
6212 let mut recorded = false;
6213 for (scope, value) in table.scope_variables.iter().zip(values) {
6214 if let Some(value) = value {
6215 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6216 recorded = true;
6217 }
6218 }
6219 recorded
6220 }
6221
6222 fn record_commit_changes(
6223 &self,
6224 batch: &mut ChangeAccumulator,
6225 tables: &[String],
6226 changes: &[ssp2::model::Change],
6227 ) {
6228 #[cfg(feature = "bench-internals")]
6229 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
6230 for change in changes {
6231 let Some(table_name) = tables.get(change.table_index as usize) else {
6232 continue;
6233 };
6234 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
6235 if let Some(table) = self.schema.table(table_name) {
6236 for (variable, value) in &change.scopes {
6237 if let Some(scope) = table
6238 .scope_variables
6239 .iter()
6240 .find(|scope| &scope.variable == variable)
6241 {
6242 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6243 precise = true;
6244 }
6245 }
6246 }
6247 if !precise {
6248 batch.table(table_name);
6249 }
6250 }
6251 }
6252
6253 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
6254 if effective.is_empty() {
6255 return false;
6256 }
6257 let Some(table) = self.schema.table(table_name) else {
6258 return false;
6259 };
6260 let mut clauses = Vec::new();
6261 let mut params = Vec::new();
6262 for (variable, values) in effective {
6263 let Some(column) = table.scope_column(variable) else {
6264 return false;
6265 };
6266 if values.is_empty() {
6267 return false;
6268 }
6269 let placeholders = values
6270 .iter()
6271 .map(|value| {
6272 params.push(SqlValue::Text(value.clone()));
6273 "?"
6274 })
6275 .collect::<Vec<_>>()
6276 .join(", ");
6277 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
6278 }
6279 let sql = format!(
6280 "SELECT 1 FROM {} WHERE {} LIMIT 1",
6281 base_table(table_name),
6282 clauses.join(" AND ")
6283 );
6284 self.conn
6285 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
6286 .is_ok()
6287 }
6288
6289 pub fn upgrading(&self) -> bool {
6291 self.upgrading
6292 }
6293
6294 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
6300 let new_schema = parse_schema_json(schema_json)?;
6301 let marker: Option<i32> = self
6302 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6303 .and_then(|v| v.parse().ok());
6304 self.schema = new_schema;
6305 self.create_bookkeeping_tables()?;
6306 if marker != Some(self.schema.version) {
6307 self.run_schema_reset()?;
6308 }
6309 self.prune_unknown_subscriptions()?;
6310 self.enqueue_startup_sync_if_needed()?;
6314 Ok(())
6315 }
6316
6317 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
6318 let startup_work = !self.stopped
6319 && (!self.outbox.is_empty()
6320 || !self.load_pending_evictions()?.is_empty()
6321 || self.subs.iter().any(|sub| sub.state == SubState::Active));
6322 if startup_work {
6323 self.sync_needed = true;
6324 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6325 }
6326 Ok(())
6327 }
6328
6329 fn clear_satisfied_persisted_schema_floor(&mut self) {
6336 let satisfied = self
6337 .schema_floor
6338 .as_ref()
6339 .and_then(|floor| floor.required_schema_version)
6340 .is_some_and(|required| self.schema.version >= required);
6341 if !satisfied {
6342 return;
6343 }
6344 self.schema_floor = None;
6345 self.stopped = false;
6346 self.delete_meta(SCHEMA_FLOOR_KEY);
6347 }
6348
6349 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
6354 let Some(replica_path) = self.previous_version_replica_path() else {
6355 return Ok(());
6356 };
6357 let max_age_ms = self
6358 .previous_version
6359 .filter(|config| config.enabled)
6360 .map(|config| config.max_age_ms);
6361 reconcile_previous_version_at_boot(
6362 &self.conn,
6363 &replica_path,
6364 self.schema.version,
6365 max_age_ms,
6366 self.clock_now_ms(),
6367 )
6368 }
6369
6370 fn previous_version_replica_path(&self) -> Option<String> {
6374 let path = self.conn.path()?;
6375 if path.is_empty() || path.starts_with("file:") {
6376 return None;
6377 }
6378 Some(path.to_owned())
6379 }
6380
6381 fn run_schema_reset(&mut self) -> Result<(), String> {
6387 self.begin_observation("syncular_schema_reset")?;
6388 let mut batch = ChangeAccumulator::default();
6389 let result = self.run_schema_reset_observed(&mut batch, true, true);
6390 if let Err(error) = result {
6391 self.rollback_observation("syncular_schema_reset");
6392 return Err(error);
6393 }
6394 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
6395 self.rollback_observation("syncular_schema_reset");
6396 return Err(error);
6397 }
6398 Ok(())
6399 }
6400
6401 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
6402 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
6403 self.begin_observation("syncular_log_epoch_reset")?;
6404 let mut batch = ChangeAccumulator::default();
6405 let result = self.run_schema_reset_observed(&mut batch, false, false);
6406 if let Err(error) = result {
6407 self.rollback_observation("syncular_log_epoch_reset");
6408 return Err(error);
6409 }
6410 self.set_meta(LOG_EPOCH_KEY, log_epoch);
6411 self.sync_needed = true;
6412 batch.status = true;
6413 if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
6414 self.rollback_observation("syncular_log_epoch_reset");
6415 return Err(error);
6416 }
6417 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6418 Ok(resets)
6419 }
6420
6421 fn run_schema_reset_observed(
6422 &mut self,
6423 batch: &mut ChangeAccumulator,
6424 drop_incompatible: bool,
6425 capture: bool,
6426 ) -> Result<(), String> {
6427 self.upgrading = true;
6428 batch.status = true;
6429 if self.previous_version.is_some_and(|config| config.enabled) {
6435 let previous_version = self
6436 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6437 .and_then(|value| value.parse::<i32>().ok())
6438 .unwrap_or(self.schema.version);
6439 let pending = self
6440 .outbox
6441 .iter()
6442 .map(|commit| PendingCommitAudit {
6443 commit_id: &commit.client_commit_id,
6444 operations: commit_audit_operations(commit),
6445 })
6446 .collect::<Vec<_>>();
6447 crate::previous_version::write_previous_version_audit(
6448 &self.conn,
6449 &crate::previous_version::build_previous_version_audit(
6450 &self.schema,
6451 &pending,
6452 previous_version,
6453 self.schema.version,
6454 self.clock_now_ms(),
6455 ),
6456 );
6457 }
6458 if let Some(replica_path) = self.previous_version_replica_path() {
6463 sweep_previous_version_container(&self.conn, &replica_path)?;
6464 if capture {
6465 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
6466 let previous_version = self
6467 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6468 .and_then(|value| value.parse::<i32>().ok())
6469 .unwrap_or(self.schema.version);
6470 capture_previous_version_from_replica(
6471 &self.conn,
6472 &replica_path,
6473 previous_version,
6474 self.schema.version,
6475 &config,
6476 self.clock_now_ms(),
6477 )?;
6478 }
6479 }
6480 }
6481 for table in &self.schema.tables {
6482 batch.table(&table.name);
6483 }
6484 for (base_key, unit, table) in self.load_registered_window_units() {
6485 batch.window(&base_key, &table, &unit);
6486 }
6487 self.insert_sql.borrow_mut().clear();
6489 self.overlay_dirty.set(true);
6490 let virtual_tables: Vec<String> = {
6498 let mut stmt = self
6499 .conn
6500 .prepare(
6501 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
6502 )
6503 .map_err(|e| e.to_string())?;
6504 let rows = stmt
6505 .query_map([], |row| row.get::<_, String>(0))
6506 .map_err(|e| e.to_string())?;
6507 rows.filter_map(Result::ok)
6508 .filter(|name| is_synced_table_name(name))
6509 .collect()
6510 };
6511 for name in virtual_tables {
6512 self.conn
6513 .execute_batch(&format!(
6514 "DROP TABLE IF EXISTS {}",
6515 quote_ident(&format!("_syncular_fts_{name}"))
6516 ))
6517 .map_err(|e| e.to_string())?;
6518 self.conn
6519 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6520 .map_err(|e| e.to_string())?;
6521 }
6522 let existing: Vec<String> = {
6523 let mut stmt = self
6524 .conn
6525 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
6526 .map_err(|e| e.to_string())?;
6527 let rows = stmt
6528 .query_map([], |row| row.get::<_, String>(0))
6529 .map_err(|e| e.to_string())?;
6530 rows.filter_map(Result::ok)
6531 .filter(|name| is_synced_table_name(name))
6532 .collect()
6533 };
6534 for name in &existing {
6535 if let Some(table) = name.strip_prefix("_syncular_base_") {
6536 batch.table(table);
6537 } else if !name.starts_with("_syncular_") {
6538 batch.table(name);
6539 }
6540 }
6541 for name in existing {
6542 self.conn
6543 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6544 .map_err(|e| e.to_string())?;
6545 }
6546 self.create_synced_tables()?;
6548 for sub in &mut self.subs {
6550 sub.cursor = -1;
6551 sub.bootstrap_state = None;
6552 sub.effective = None;
6553 sub.state = SubState::Active;
6554 sub.reason_code = None;
6555 sub.synced_once = false;
6556 }
6557 let subs = self.subs.clone();
6558 for sub in &subs {
6559 self.persist_sub(sub)?;
6560 }
6561 self.stopped = false;
6563 self.schema_floor = None;
6564 self.delete_meta(SCHEMA_FLOOR_KEY);
6565 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
6567 set_local_schema_descriptor(&self.conn, &self.schema);
6570 if drop_incompatible && self.drop_incompatible_outbox()? {
6574 batch.rejections = true;
6575 batch.status = true;
6576 batch.outcomes = true;
6577 }
6578 self.rebuild_overlay();
6580 Ok(())
6581 }
6582
6583 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
6587 let incompatible = self
6588 .outbox
6589 .iter()
6590 .filter(|commit| {
6591 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
6592 })
6593 .cloned()
6594 .collect::<Vec<_>>();
6595 if incompatible.is_empty() {
6596 return Ok(false);
6597 }
6598 let mut rejections = Vec::new();
6599 for commit in &incompatible {
6600 let results = commit
6601 .ops
6602 .iter()
6603 .enumerate()
6604 .map(|(op_index, operation)| {
6605 let rejection = RejectionRecord {
6606 client_commit_id: commit.client_commit_id.clone(),
6607 op_index: op_index as i32,
6608 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
6609 message: "the persisted commit cannot encode under the current schema"
6610 .to_owned(),
6611 retryable: false,
6612 details: None,
6613 operation: Some(CommitOperation::from(operation)),
6614 };
6615 rejections.push(rejection.clone());
6616 CommitOperationOutcome::Error { rejection }
6617 })
6618 .collect::<Vec<_>>();
6619 self.persist_commit_outcome(
6620 &commit.client_commit_id,
6621 CommitOutcomeStatus::Rejected,
6622 &results,
6623 Some(&commit.ops),
6624 )?;
6625 self.delete_outbox_persisted(&commit.client_commit_id)?;
6626 }
6627 self.prune_commit_outcomes()?;
6628 let incompatible_ids = incompatible
6629 .iter()
6630 .map(|commit| commit.client_commit_id.as_str())
6631 .collect::<BTreeSet<_>>();
6632 self.outbox
6633 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
6634 self.rejections.extend(rejections);
6635 Ok(true)
6636 }
6637
6638 fn create_synced_tables(&self) -> Result<(), String> {
6641 for table in &self.schema.tables {
6642 for (full, index_prefix) in [
6646 (base_table(&table.name), "_syncular_base_"),
6647 (visible_table(&table.name), ""),
6648 ] {
6649 let mut cols: Vec<String> =
6650 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
6651 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
6652 let sql = format!(
6653 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
6654 cols.join(", "),
6655 quote_ident(&table.primary_key)
6656 );
6657 self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
6658 for index in &table.indexes {
6662 let unique = if index.unique { "UNIQUE " } else { "" };
6663 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
6664 let cols_sql = index
6665 .columns
6666 .iter()
6667 .map(|c| quote_ident(c))
6668 .collect::<Vec<_>>()
6669 .join(", ");
6670 let index_sql = format!(
6671 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
6672 );
6673 self.conn
6674 .execute(&index_sql, [])
6675 .map_err(|e| e.to_string())?;
6676 }
6677 }
6678 }
6679 for table in &self.schema.tables {
6682 for index in &table.fts_indexes {
6683 self.create_fts_projection(table, index)?;
6684 }
6685 }
6686 Ok(())
6687 }
6688
6689 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
6690 let count: i64 = self
6691 .conn
6692 .query_row(
6693 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
6694 rusqlite::params![index.name],
6695 |row| row.get(0),
6696 )
6697 .map_err(|e| e.to_string())?;
6698 Ok(count > 0)
6699 }
6700
6701 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
6702 for suffix in ["ai", "ad", "au"] {
6703 self.conn
6704 .execute(
6705 &format!(
6706 "DROP TRIGGER IF EXISTS {}",
6707 quote_ident(&format!("{}_{suffix}", index.name))
6708 ),
6709 [],
6710 )
6711 .map_err(|e| e.to_string())?;
6712 }
6713 Ok(())
6714 }
6715
6716 fn create_fts_triggers(
6717 &self,
6718 table: &TableSchema,
6719 index: &FtsIndexSchema,
6720 ) -> Result<(), String> {
6721 let fts = quote_ident(&index.name);
6722 let source = visible_table(&table.name);
6723 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
6724 let pk = quote_ident(&table.primary_key);
6725 let projection_columns = std::iter::once(source_id.clone())
6726 .chain(index.columns.iter().map(|column| quote_ident(column)))
6727 .collect::<Vec<_>>()
6728 .join(", ");
6729 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
6730 .chain(
6731 index
6732 .columns
6733 .iter()
6734 .map(|column| format!("new.{}", quote_ident(column))),
6735 )
6736 .collect::<Vec<_>>()
6737 .join(", ");
6738 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6739 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)");
6740 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)");
6741 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})");
6742 let bi = quote_ident(&format!("{}_bi", index.name));
6743 let ai = quote_ident(&format!("{}_ai", index.name));
6744 let ad = quote_ident(&format!("{}_ad", index.name));
6745 let au = quote_ident(&format!("{}_au", index.name));
6746 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
6747 let sql = format!(
6748 "DROP TRIGGER IF EXISTS {bi};
6749 DROP TRIGGER IF EXISTS {ai};
6750 DROP TRIGGER IF EXISTS {ad};
6751 DROP TRIGGER IF EXISTS {au};
6752 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
6753 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
6754 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
6755 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
6756 );
6757 self.conn.execute_batch(&sql).map_err(|e| e.to_string())
6758 }
6759
6760 fn rebuild_fts_projection(
6761 &self,
6762 table: &TableSchema,
6763 index: &FtsIndexSchema,
6764 ) -> Result<(), String> {
6765 let fts = quote_ident(&index.name);
6766 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
6767 let pk = quote_ident(&table.primary_key);
6768 let indexed_columns = index
6769 .columns
6770 .iter()
6771 .map(|column| quote_ident(column))
6772 .collect::<Vec<_>>();
6773 let projection_columns = std::iter::once(source_id.clone())
6774 .chain(indexed_columns.iter().cloned())
6775 .collect::<Vec<_>>()
6776 .join(", ");
6777 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6778 let sql = format!(
6779 "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};",
6780 columns = indexed_columns.join(", "),
6781 source = visible_table(&table.name),
6782 );
6783 self.conn.execute_batch(&sql).map_err(|e| e.to_string())
6784 }
6785
6786 fn create_fts_projection(
6787 &self,
6788 table: &TableSchema,
6789 index: &FtsIndexSchema,
6790 ) -> Result<(), String> {
6791 self.begin_observation("syncular_fts_schema")?;
6792 let result = (|| {
6793 let existed = self.fts_projection_exists(index)?;
6794 let tokenizer = index.tokenize.replace('\'', "''");
6795 let columns = index
6796 .columns
6797 .iter()
6798 .map(|column| quote_ident(column))
6799 .collect::<Vec<_>>()
6800 .join(", ");
6801 let sql = format!(
6802 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
6803 fts = quote_ident(&index.name),
6804 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
6805 );
6806 self.conn.execute(&sql, []).map_err(|error| {
6807 format!(
6808 "cannot create local FTS5 projection {:?}: {error}",
6809 index.name
6810 )
6811 })?;
6812 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6813 let mapped: bool = self
6814 .conn
6815 .query_row(
6816 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
6817 [format!("_syncular_fts_{}", index.name)],
6818 |row| row.get(0),
6819 )
6820 .map_err(|e| e.to_string())?;
6821 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())?;
6822 if !mapped && existed {
6823 self.conn
6824 .execute_batch(&format!(
6825 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
6826 quote_ident(FTS_SOURCE_ID_COLUMN),
6827 quote_ident(&index.name)
6828 ))
6829 .map_err(|e| e.to_string())?;
6830 }
6831 self.create_fts_triggers(table, index)?;
6832 if !existed {
6833 self.rebuild_fts_projection(table, index)?;
6834 }
6835 Ok(())
6836 })();
6837 if let Err(error) = result {
6838 self.rollback_observation("syncular_fts_schema");
6839 return Err(error);
6840 }
6841 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
6842 self.rollback_observation("syncular_fts_schema");
6843 return Err(error.to_string());
6844 }
6845 Ok(())
6846 }
6847
6848 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
6851 let state = serde_json::json!({
6852 "requested": scope_map_to_json(&sub.requested),
6853 "params": sub.params,
6854 "cursor": sub.cursor,
6855 "bootstrapState": sub.bootstrap_state,
6856 "status": sub.state.name(),
6857 "reasonCode": sub.reason_code,
6858 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
6859 "syncedOnce": sub.synced_once,
6860 });
6861 self.conn.execute(
6862 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
6863 rusqlite::params![sub.id, sub.table, state.to_string()],
6864 ).map(|_| ()).map_err(|error| error.to_string())
6865 }
6866
6867 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
6868 let ops: Vec<Value> = commit
6869 .ops
6870 .iter()
6871 .map(|op| {
6872 serde_json::json!({
6873 "op": if op.upsert { "upsert" } else { "delete" },
6874 "table": op.table,
6875 "rowId": op.row_id,
6876 "baseVersion": op.base_version,
6877 "values": op.values.clone().map(Value::Object),
6878 })
6879 })
6880 .collect();
6881 self.conn
6882 .execute(
6883 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
6884 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
6885 )
6886 .map_err(|error| error.to_string())?;
6887 for operation in &commit.ops {
6888 let Some(values) = &operation.values else {
6889 continue;
6890 };
6891 let Some(table) = self.schema.table(&operation.table) else {
6892 continue;
6893 };
6894 for column in table
6895 .columns
6896 .iter()
6897 .filter(|column| column.ty == ColumnType::BlobRef)
6898 {
6899 let Some(Value::String(raw)) = values.get(&column.name) else {
6900 continue;
6901 };
6902 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
6903 continue;
6904 };
6905 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
6906 continue;
6907 };
6908 self.conn
6909 .execute(
6910 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
6911 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
6912 rusqlite::params![commit.client_commit_id, blob_id],
6913 )
6914 .map_err(|error| error.to_string())?;
6915 }
6916 }
6917 Ok(())
6918 }
6919
6920 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
6921 self.conn
6922 .execute(
6923 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
6924 rusqlite::params![client_commit_id],
6925 )
6926 .map_err(|error| error.to_string())?;
6927 self.conn
6928 .execute(
6929 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
6930 rusqlite::params![client_commit_id],
6931 )
6932 .map(|_| ())
6933 .map_err(|error| error.to_string())
6934 }
6935
6936 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
6937 match status {
6938 CommitOutcomeStatus::Applied => "applied",
6939 CommitOutcomeStatus::Cached => "cached",
6940 CommitOutcomeStatus::Conflict => "conflict",
6941 CommitOutcomeStatus::Rejected => "rejected",
6942 }
6943 }
6944
6945 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
6946 match resolution {
6947 CommitOutcomeResolution::Active => "active",
6948 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
6949 CommitOutcomeResolution::Superseded => "superseded",
6950 CommitOutcomeResolution::Dismissed => "dismissed",
6951 }
6952 }
6953
6954 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
6955 match value {
6956 "applied" => Ok(CommitOutcomeStatus::Applied),
6957 "cached" => Ok(CommitOutcomeStatus::Cached),
6958 "conflict" => Ok(CommitOutcomeStatus::Conflict),
6959 "rejected" => Ok(CommitOutcomeStatus::Rejected),
6960 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
6961 }
6962 }
6963
6964 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
6965 match value {
6966 "active" => Ok(CommitOutcomeResolution::Active),
6967 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
6968 "superseded" => Ok(CommitOutcomeResolution::Superseded),
6969 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
6970 _ => Err(format!(
6971 "invalid persisted commit outcome resolution {value:?}"
6972 )),
6973 }
6974 }
6975
6976 fn persist_commit_outcome(
6977 &self,
6978 client_commit_id: &str,
6979 status: CommitOutcomeStatus,
6980 results: &[CommitOperationOutcome],
6981 operations: Option<&[OutboxOp]>,
6982 ) -> Result<(), String> {
6983 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
6984 let operations_json = operations
6985 .map(|items| {
6986 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
6987 })
6988 .transpose()
6989 .map_err(|error| error.to_string())?;
6990 self.conn
6991 .execute(
6992 "INSERT INTO _syncular_commit_outcomes (
6993 client_commit_id, status, recorded_at_ms, results_json,
6994 operations_json, resolution
6995 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
6996 rusqlite::params![
6997 client_commit_id,
6998 Self::outcome_status_name(status),
6999 self.clock_now_ms(),
7000 results_json,
7001 operations_json
7002 ],
7003 )
7004 .map(|_| ())
7005 .map_err(|error| error.to_string())
7006 }
7007
7008 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
7009 let StoredCommitOutcomeRow {
7010 sequence,
7011 client_commit_id,
7012 status,
7013 recorded_at_ms,
7014 results_json,
7015 operations_json,
7016 resolution,
7017 resolved_at_ms,
7018 replacement_client_commit_id,
7019 } = row;
7020 Ok(CommitOutcome {
7021 sequence,
7022 client_commit_id,
7023 status: Self::parse_outcome_status(&status)?,
7024 recorded_at_ms,
7025 results: serde_json::from_str(&results_json)
7026 .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
7027 operations: operations_json
7028 .map(|value| {
7029 serde_json::from_str(&value).map_err(|error| {
7030 format!("invalid persisted commit outcome operations: {error}")
7031 })
7032 })
7033 .transpose()?,
7034 resolution: Self::parse_outcome_resolution(&resolution)?,
7035 resolved_at_ms,
7036 replacement_client_commit_id,
7037 })
7038 }
7039
7040 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
7041 let row = self
7042 .conn
7043 .query_row(
7044 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7045 resolution, resolved_at_ms, replacement_client_commit_id
7046 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
7047 rusqlite::params![client_commit_id],
7048 |row| {
7049 Ok(StoredCommitOutcomeRow {
7050 sequence: row.get(0)?,
7051 client_commit_id: row.get(1)?,
7052 status: row.get(2)?,
7053 recorded_at_ms: row.get(3)?,
7054 results_json: row.get(4)?,
7055 operations_json: row.get(5)?,
7056 resolution: row.get(6)?,
7057 resolved_at_ms: row.get(7)?,
7058 replacement_client_commit_id: row.get(8)?,
7059 })
7060 },
7061 )
7062 .optional()
7063 .map_err(|error| error.to_string())?;
7064 row.map(Self::outcome_from_row).transpose()
7065 }
7066
7067 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
7068 if query.limit == Some(0) {
7069 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
7070 }
7071 let mut sql = String::from(
7072 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7073 resolution, resolved_at_ms, replacement_client_commit_id
7074 FROM _syncular_commit_outcomes",
7075 );
7076 if query.active_only {
7077 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
7078 }
7079 sql.push_str(" ORDER BY seq DESC");
7080 if let Some(limit) = query.limit {
7081 sql.push_str(&format!(" LIMIT {limit}"));
7082 }
7083 let mut stmt = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
7084 let rows = stmt
7085 .query_map([], |row| {
7086 Ok(StoredCommitOutcomeRow {
7087 sequence: row.get(0)?,
7088 client_commit_id: row.get(1)?,
7089 status: row.get(2)?,
7090 recorded_at_ms: row.get(3)?,
7091 results_json: row.get(4)?,
7092 operations_json: row.get(5)?,
7093 resolution: row.get(6)?,
7094 resolved_at_ms: row.get(7)?,
7095 replacement_client_commit_id: row.get(8)?,
7096 })
7097 })
7098 .map_err(|error| error.to_string())?;
7099 let mut outcomes = Vec::new();
7100 for row in rows {
7101 outcomes.push(Self::outcome_from_row(
7102 row.map_err(|error| error.to_string())?,
7103 )?);
7104 }
7105 Ok(outcomes)
7106 }
7107
7108 fn prune_commit_outcomes(&self) -> Result<(), String> {
7109 #[cfg(test)]
7110 self.outcome_prune_count
7111 .set(self.outcome_prune_count.get() + 1);
7112 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
7113 let count = self
7114 .conn
7115 .query_row(
7116 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
7117 [],
7118 |row| row.get::<_, i64>(0),
7119 )
7120 .map_err(|error| error.to_string())? as usize;
7121 let excess = count.saturating_sub(max_entries);
7122 if excess == 0 {
7123 return Ok(());
7124 }
7125 let mut stmt = self
7126 .conn
7127 .prepare(
7128 "SELECT seq FROM _syncular_commit_outcomes
7129 WHERE status IN ('applied', 'cached') OR resolution != 'active'
7130 ORDER BY seq ASC LIMIT ?1",
7131 )
7132 .map_err(|error| error.to_string())?;
7133 let rows = stmt
7134 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
7135 .map_err(|error| error.to_string())?;
7136 let sequences = rows
7137 .collect::<Result<Vec<_>, _>>()
7138 .map_err(|error| error.to_string())?;
7139 drop(stmt);
7140 for sequence in sequences {
7141 self.conn
7142 .execute(
7143 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
7144 rusqlite::params![sequence],
7145 )
7146 .map_err(|error| error.to_string())?;
7147 }
7148 Ok(())
7149 }
7150
7151 pub fn subscribe(
7154 &mut self,
7155 id: String,
7156 table: String,
7157 scopes: Vec<(String, Vec<String>)>,
7158 params: Option<String>,
7159 ) -> Result<(), String> {
7160 if self.schema.table(&table).is_none() {
7161 return Err(format!("unknown table {table:?}"));
7162 }
7163 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
7164 let same_intent = existing.table == table
7165 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
7166 && existing.params == params;
7167 if same_intent {
7168 return Ok(());
7169 }
7170 return Err(
7171 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
7172 .to_owned(),
7173 );
7174 }
7175 let sub = Subscription {
7176 id: id.clone(),
7177 table,
7178 requested: scopes,
7179 params,
7180 cursor: -1,
7181 bootstrap_state: None,
7182 state: SubState::Active,
7183 reason_code: None,
7184 effective: None,
7185 synced_once: false,
7186 };
7187 self.persist_sub(&sub)?;
7188 self.subs.push(sub);
7189 Ok(())
7190 }
7191
7192 pub fn unsubscribe(&mut self, id: &str) {
7193 self.subs.retain(|s| s.id != id);
7194 let _ = self.conn.execute(
7195 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7196 rusqlite::params![id],
7197 );
7198 }
7199
7200 pub fn set_window(
7208 &mut self,
7209 base: &WindowBase,
7210 units: &[String],
7211 ) -> Result<CommandEffects, String> {
7212 let table = self
7213 .schema
7214 .table(&base.table)
7215 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
7216 if table.scope_column(&base.variable).is_none() {
7217 return Err(format!(
7218 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
7219 base.table, base.variable
7220 ));
7221 }
7222 let base_key = window_base_key(base);
7223 let wanted: std::collections::HashSet<&String> = units.iter().collect();
7224 let live = self.load_window_units(&base_key);
7225 self.begin_observation("syncular_window")?;
7226 let mut batch = ChangeAccumulator::default();
7227 let mut changed = false;
7228
7229 let prior_subs = self.subs.clone();
7230 let widened = (|| {
7231 for unit in units {
7233 if live.iter().any(|(u, _)| u == unit) {
7234 continue;
7235 }
7236 let sub_id = derive_sub_id(base, unit);
7237 self.delete_pending_evict(&sub_id)?;
7238 self.insert_window_unit(&base_key, unit, &sub_id)?;
7239 self.subscribe(
7240 sub_id,
7241 base.table.clone(),
7242 unit_scopes(base, unit),
7243 base.params.clone(),
7244 )?;
7245 batch.window(&base_key, &base.table, unit);
7246 changed = true;
7247 }
7248
7249 self.finish_observation("syncular_window", batch)
7250 })();
7251 if let Err(error) = widened {
7252 self.rollback_observation("syncular_window");
7253 self.subs = prior_subs;
7254 return Err(error);
7255 }
7256 for (unit, sub_id) in live {
7257 if wanted.contains(&unit) {
7258 continue;
7259 }
7260 self.rebuild_overlay_if_dirty();
7261 let prior_subs = self.subs.clone();
7262 self.begin_observation("syncular_window")?;
7263 let effective = self
7264 .subs
7265 .iter()
7266 .find(|sub| sub.id == sub_id)
7267 .and_then(|sub| sub.effective.clone())
7268 .unwrap_or_else(|| unit_scopes(base, &unit));
7269 let mut batch = ChangeAccumulator::default();
7270 self.record_scope_map(&mut batch, &base.table, &effective);
7271 batch.window(&base_key, &base.table, &unit);
7272 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
7273 let remaining = match result {
7274 Ok(remaining) => remaining,
7275 Err(error) => {
7276 self.rollback_observation("syncular_window");
7277 self.subs = prior_subs;
7278 return Err(error);
7279 }
7280 };
7281 if let Err(error) = self.finish_observation("syncular_window", batch) {
7282 self.rollback_observation("syncular_window");
7283 self.subs = prior_subs;
7284 return Err(error);
7285 }
7286 if remaining {
7287 self.drain_pending_evictions()?;
7288 }
7289 changed = true;
7290 }
7291 Ok(if changed {
7292 CommandEffects::interactive()
7293 } else {
7294 CommandEffects::none()
7295 })
7296 }
7297
7298 pub fn window_state(&self, base: &WindowBase) -> WindowState {
7303 let mut units = Vec::new();
7304 let mut pending = Vec::new();
7305 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
7306 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
7307 Some(sub) => {
7308 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
7309 }
7310 None => true,
7311 };
7312 if is_pending {
7313 pending.push(unit.clone());
7314 }
7315 units.push(unit);
7316 }
7317 WindowState { units, pending }
7318 }
7319
7320 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
7321 let mut stmt = match self
7322 .conn
7323 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
7324 {
7325 Ok(stmt) => stmt,
7326 Err(_) => return Vec::new(),
7327 };
7328 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
7329 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7330 });
7331 match rows {
7332 Ok(rows) => rows.filter_map(Result::ok).collect(),
7333 Err(_) => Vec::new(),
7334 }
7335 }
7336
7337 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
7338 let mut stmt = match self.conn.prepare(
7339 "SELECT windows.base, windows.unit, subscriptions.tbl
7340 FROM _syncular_windows AS windows
7341 JOIN _syncular_subscriptions AS subscriptions
7342 ON subscriptions.id = windows.sub_id
7343 ORDER BY windows.base, windows.unit",
7344 ) {
7345 Ok(stmt) => stmt,
7346 Err(_) => return Vec::new(),
7347 };
7348 let rows = stmt.query_map([], |row| {
7349 Ok((
7350 row.get::<_, String>(0)?,
7351 row.get::<_, String>(1)?,
7352 row.get::<_, String>(2)?,
7353 ))
7354 });
7355 match rows {
7356 Ok(rows) => rows.filter_map(Result::ok).collect(),
7357 Err(_) => Vec::new(),
7358 }
7359 }
7360
7361 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
7362 self.conn
7363 .query_row(
7364 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
7365 rusqlite::params![sub_id],
7366 |row| Ok((row.get(0)?, row.get(1)?)),
7367 )
7368 .ok()
7369 }
7370
7371 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
7372 self.conn
7373 .execute(
7374 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
7375 rusqlite::params![base_key, unit, sub_id],
7376 )
7377 .map(|_| ())
7378 .map_err(|error| error.to_string())
7379 }
7380
7381 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
7382 self.conn
7383 .execute(
7384 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
7385 rusqlite::params![base_key, unit],
7386 )
7387 .map(|_| ())
7388 .map_err(|error| error.to_string())
7389 }
7390
7391 fn evict_unit(
7398 &mut self,
7399 base_key: &str,
7400 base: &WindowBase,
7401 unit: &str,
7402 sub_id: &str,
7403 ) -> Result<bool, String> {
7404 let effective = self
7405 .subs
7406 .iter()
7407 .find(|s| s.id == sub_id)
7408 .and_then(|s| s.effective.clone())
7409 .unwrap_or_else(|| unit_scopes(base, unit));
7410 let pinned = self.pinned_row_ids(&base.table);
7411 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
7412 self.delete_window_unit(base_key, unit)?;
7413 self.conn
7414 .execute(
7415 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7416 [sub_id],
7417 )
7418 .map_err(|error| error.to_string())?;
7419 self.subs.retain(|sub| sub.id != sub_id);
7420 if deferred {
7421 self.save_pending_evict(sub_id, &base.table, &effective)?;
7422 } else {
7423 self.delete_pending_evict(sub_id)?;
7424 }
7425 Ok(remaining)
7426 }
7427
7428 fn evict_scope_rows(
7432 &self,
7433 table_name: &str,
7434 effective: &[(String, Vec<String>)],
7435 pinned: &std::collections::HashSet<String>,
7436 ) -> Result<(bool, bool), String> {
7437 if effective.is_empty() {
7438 return Ok((false, false));
7439 }
7440 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
7441 let mut clauses = Vec::new();
7442 let mut params: Vec<SqlValue> = Vec::new();
7443 for (variable, values) in effective {
7444 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
7445 if values.is_empty() {
7446 return Ok((false, false));
7447 }
7448 let holes = values
7449 .iter()
7450 .map(|v| {
7451 params.push(SqlValue::Text(v.clone()));
7452 "?"
7453 })
7454 .collect::<Vec<_>>()
7455 .join(", ");
7456 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
7457 }
7458 let scope = clauses.join(" AND ");
7459 let scope_params = params.len();
7460 let pk = quote_ident(&table.primary_key);
7461 if !pinned.is_empty() {
7462 let holes = pinned
7463 .iter()
7464 .map(|id| {
7465 params.push(SqlValue::Text(id.clone()));
7466 "?"
7467 })
7468 .collect::<Vec<_>>()
7469 .join(", ");
7470 clauses.push(format!("{pk} NOT IN ({holes})"));
7471 }
7472 let predicate = clauses.join(" AND ");
7473 let base = base_table(table_name);
7474 let visible = visible_table(table_name);
7475 let mut ids: Vec<SqlValue> = Vec::new();
7476 for source in [&base, &visible] {
7477 let mut stmt = self
7478 .conn
7479 .prepare_cached(&format!(
7480 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
7481 ))
7482 .map_err(|e| e.to_string())?;
7483 ids = stmt
7484 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
7485 .map_err(|e| e.to_string())?
7486 .collect::<Result<_, _>>()
7487 .map_err(|e| e.to_string())?;
7488 if !ids.is_empty() {
7489 break;
7490 }
7491 }
7492 if !ids.is_empty() {
7493 let holes = vec!["?"; ids.len()].join(", ");
7494 for source in [&base, &visible] {
7495 self.conn
7496 .execute(
7497 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
7498 rusqlite::params_from_iter(&ids),
7499 )
7500 .map_err(|e| e.to_string())?;
7501 }
7502 }
7503 let mut remaining = false;
7504 let mut deferred = false;
7505 for source in [&base, &visible] {
7506 remaining |= self
7507 .conn
7508 .query_row(
7509 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
7510 rusqlite::params_from_iter(¶ms),
7511 |row| row.get::<_, bool>(0),
7512 )
7513 .map_err(|e| e.to_string())?;
7514 deferred |= self
7515 .conn
7516 .query_row(
7517 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
7518 rusqlite::params_from_iter(¶ms[..scope_params]),
7519 |row| row.get::<_, bool>(0),
7520 )
7521 .map_err(|e| e.to_string())?;
7522 }
7523 Ok((remaining, deferred))
7524 }
7525
7526 fn drain_pending_evictions(&mut self) -> Result<(), String> {
7528 let pending = self.load_pending_evictions()?;
7529 if pending.is_empty() {
7530 return Ok(());
7531 }
7532 self.rebuild_overlay_if_dirty();
7533 for (sub_id, table_name, effective) in pending {
7534 if self.schema.table(&table_name).is_none() {
7535 self.delete_pending_evict(&sub_id)?;
7536 continue;
7537 }
7538 loop {
7539 std::thread::yield_now();
7540 self.begin_observation("syncular_evict")?;
7541 let pinned = self.pinned_row_ids(&table_name);
7542 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
7543 let (remaining, deferred) = match result {
7544 Ok(result) => result,
7545 Err(error) => {
7546 self.rollback_observation("syncular_evict");
7547 return Err(error);
7548 }
7549 };
7550 if !deferred {
7551 if let Err(error) = self.delete_pending_evict(&sub_id) {
7552 self.rollback_observation("syncular_evict");
7553 return Err(error);
7554 }
7555 }
7556 let mut batch = ChangeAccumulator::default();
7557 self.record_scope_map(&mut batch, &table_name, &effective);
7558 if let Err(error) = self.finish_observation("syncular_evict", batch) {
7559 self.rollback_observation("syncular_evict");
7560 return Err(error);
7561 }
7562 if !remaining {
7563 break;
7564 }
7565 }
7566 }
7567 Ok(())
7568 }
7569
7570 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
7573 let mut pinned = std::collections::HashSet::new();
7574 for commit in &self.outbox {
7575 for op in &commit.ops {
7576 if op.table == table {
7577 pinned.insert(op.row_id.clone());
7578 }
7579 }
7580 }
7581 pinned
7582 }
7583
7584 fn save_pending_evict(
7585 &self,
7586 sub_id: &str,
7587 table: &str,
7588 effective: &[(String, Vec<String>)],
7589 ) -> Result<(), String> {
7590 self.conn.execute(
7591 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
7592 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
7593 ).map(|_| ()).map_err(|error| error.to_string())
7594 }
7595
7596 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
7597 self.conn
7598 .execute(
7599 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
7600 rusqlite::params![sub_id],
7601 )
7602 .map(|_| ())
7603 .map_err(|error| error.to_string())
7604 }
7605
7606 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
7607 let mut stmt = self
7608 .conn
7609 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
7610 .map_err(|error| error.to_string())?;
7611 let rows = stmt
7612 .query_map([], |row| {
7613 Ok((
7614 row.get::<_, String>(0)?,
7615 row.get::<_, String>(1)?,
7616 row.get::<_, String>(2)?,
7617 ))
7618 })
7619 .map_err(|error| error.to_string())?;
7620 rows.map(|row| {
7621 let (sub_id, table, json) = row.map_err(|error| error.to_string())?;
7622 let value: Value = serde_json::from_str(&json)
7623 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7624 let effective = json_to_scope_map(&value)
7625 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7626 Ok((sub_id, table, effective))
7627 })
7628 .collect()
7629 }
7630
7631 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
7633 if mutations.is_empty() {
7634 return Err("a commit must contain at least one operation (§6.1)".to_owned());
7635 }
7636 let mut ops = Vec::with_capacity(mutations.len());
7637 for mutation in mutations {
7638 match mutation {
7639 Mutation::Upsert {
7640 table,
7641 values,
7642 base_version,
7643 } => {
7644 let schema_table = self
7645 .schema
7646 .table(&table)
7647 .ok_or_else(|| format!("unknown table {table:?}"))?;
7648 let values = normalize_values_casing(schema_table, values)?;
7652 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
7653 let values = full_row_values(schema_table, values)?;
7656 self.validate_author_encode(schema_table, &row_id, &values)?;
7662 ops.push(OutboxOp {
7663 upsert: true,
7664 table,
7665 row_id,
7666 base_version,
7667 values: Some(values),
7668 });
7669 }
7670 Mutation::Delete {
7671 table,
7672 row_id,
7673 base_version,
7674 } => {
7675 if self.schema.table(&table).is_none() {
7676 return Err(format!("unknown table {table:?}"));
7677 }
7678 ops.push(OutboxOp {
7679 upsert: false,
7680 table,
7681 row_id,
7682 base_version,
7683 values: None,
7684 });
7685 }
7686 }
7687 }
7688 self.record_outbox_commit(ops)
7689 }
7690
7691 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
7692 let commit = OutboxCommit {
7693 client_commit_id: uuid::Uuid::new_v4().to_string(),
7694 ops,
7695 };
7696 self.begin_observation("syncular_mutation")?;
7697 let mut batch = ChangeAccumulator::default();
7698 for op in &commit.ops {
7699 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
7700 if let Some(values) = &op.values {
7701 if let Some(table) = self.schema.table(&op.table) {
7702 for scope in &table.scope_variables {
7703 if let Some(Value::String(value)) = values.get(&scope.column) {
7704 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
7705 precise = true;
7706 }
7707 }
7708 }
7709 }
7710 if !precise {
7711 batch.table(&op.table);
7712 }
7713 }
7714 if let Err(error) = self.persist_outbox_insert(&commit) {
7715 self.rollback_observation("syncular_mutation");
7716 return Err(error);
7717 }
7718 let was_dirty = self.overlay_dirty.get();
7719 if !was_dirty {
7722 self.apply_outbox_ops(&commit.ops);
7723 }
7724 let id = commit.client_commit_id.clone();
7725 self.outbox.push(commit);
7726 if was_dirty {
7727 self.rebuild_overlay();
7728 }
7729 batch.status = true;
7730 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
7731 self.rollback_observation("syncular_mutation");
7732 self.outbox.pop();
7733 self.overlay_dirty.set(was_dirty);
7734 return Err(error);
7735 }
7736 Ok(id)
7737 }
7738
7739 pub fn patch(
7745 &mut self,
7746 table: &str,
7747 row_id: &str,
7748 partial: Map<String, Value>,
7749 base_version: Option<i64>,
7750 ) -> Result<String, String> {
7751 let schema_table = self
7752 .schema
7753 .table(table)
7754 .ok_or_else(|| format!("unknown table {table:?}"))?;
7755 let partial = normalize_values_casing(schema_table, partial)?;
7756 if let Some(pk) = partial.get(&schema_table.primary_key) {
7757 if pk.as_str() != Some(row_id) {
7758 return Err(format!(
7759 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
7760 ));
7761 }
7762 }
7763 for scope in &schema_table.scope_variables {
7773 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
7774 continue;
7775 }
7776 let Some(column) = schema_table
7777 .columns
7778 .iter()
7779 .find(|column| column.name == scope.column)
7780 else {
7781 continue;
7782 };
7783 let stored = self
7784 .conn
7785 .query_row(
7786 &format!(
7787 "SELECT {} FROM {} WHERE {}",
7788 quote_ident(&scope.column),
7789 visible_table(&schema_table.name),
7790 row_id_predicate(schema_table)
7791 ),
7792 rusqlite::params![row_id],
7793 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
7794 )
7795 .ok();
7796 let incoming = json_to_column_value(column, partial.get(&scope.column))
7801 .ok()
7802 .map(|value| column_value_to_json(&value));
7803 let equal = stored.as_ref().is_some_and(|stored| {
7807 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
7808 });
7809 if !equal {
7810 return Err(format!(
7811 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
7812 scope.column
7813 ));
7814 }
7815 }
7816 let mut values = partial;
7817 for scope in &schema_table.scope_variables {
7820 values.remove(&scope.column);
7821 }
7822 values.insert(
7823 schema_table.primary_key.clone(),
7824 Value::from(row_id.to_owned()),
7825 );
7826 self.validate_author_encode(schema_table, row_id, &values)?;
7832 self.record_outbox_commit(vec![OutboxOp {
7833 upsert: true,
7834 table: table.to_owned(),
7835 row_id: row_id.to_owned(),
7836 base_version,
7837 values: Some(values),
7838 }])
7839 }
7840
7841 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
7846 let selector = self.encryption.key_id_columns.get(&table.name)?;
7847 let index = table
7848 .columns
7849 .iter()
7850 .position(|column| &column.name == selector)?;
7851 let sql = format!(
7852 "SELECT {} FROM {} WHERE {}",
7853 quote_ident(selector),
7854 visible_table(&table.name),
7855 row_id_predicate(table)
7856 );
7857 let key: Option<String> = self
7858 .conn
7859 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
7860 .ok()?;
7861 let key = key.filter(|key| !key.is_empty())?;
7862 let mut fallback: Row = vec![None; table.columns.len()];
7863 fallback[index] = Some(ColumnValue::String(key));
7864 Some(fallback)
7865 }
7866
7867 fn validate_author_encode(
7872 &self,
7873 table: &TableSchema,
7874 row_id: &str,
7875 values: &Map<String, Value>,
7876 ) -> Result<(), String> {
7877 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
7878 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
7879 result => result.map(|_| ()),
7880 }
7881 }
7882
7883 fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
7889 if !self
7890 .schema
7891 .tables
7892 .iter()
7893 .any(|table| table.has_encrypted_columns())
7894 {
7895 return Ok(false);
7896 }
7897 let failures = self
7898 .outbox
7899 .iter()
7900 .filter_map(|commit| {
7901 commit
7902 .ops
7903 .iter()
7904 .find_map(|op| {
7905 let values = op.values.as_ref().filter(|_| op.upsert)?;
7906 let table = self.schema.table(&op.table)?;
7907 let fallback = self.stored_key_fallback(table, &op.row_id);
7908 match encode_sparse_row_json(
7909 table,
7910 &op.row_id,
7911 values,
7912 &self.encryption,
7913 fallback.as_ref(),
7914 ) {
7915 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
7916 _ => None,
7917 }
7918 })
7919 .map(|error| (commit.clone(), error))
7920 })
7921 .collect::<Vec<_>>();
7922 if failures.is_empty() {
7923 return Ok(false);
7924 }
7925 for (commit, message) in failures {
7926 let rejection = RejectionRecord {
7927 client_commit_id: commit.client_commit_id.clone(),
7928 op_index: 0,
7929 code: ENCRYPT_FAILED_CODE.to_owned(),
7930 message,
7931 retryable: false,
7932 details: None,
7933 operation: commit.ops.first().map(CommitOperation::from),
7934 };
7935 self.persist_commit_outcome(
7936 &commit.client_commit_id,
7937 CommitOutcomeStatus::Rejected,
7938 &[CommitOperationOutcome::Error {
7939 rejection: rejection.clone(),
7940 }],
7941 Some(&commit.ops),
7942 )?;
7943 self.delete_outbox_persisted(&commit.client_commit_id)?;
7944 self.outbox
7945 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
7946 self.rejections.push(rejection);
7947 }
7948 self.prune_commit_outcomes()?;
7949 self.overlay_dirty.set(true);
7950 self.rebuild_overlay();
7951 Ok(true)
7952 }
7953
7954 pub fn pending_commit_ids(&self) -> Vec<String> {
7955 self.outbox
7956 .iter()
7957 .map(|c| c.client_commit_id.clone())
7958 .collect()
7959 }
7960
7961 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
7965 let mut payloads = Vec::new();
7966 for commit in &self.outbox {
7967 for op in &commit.ops {
7968 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
7969 continue;
7970 };
7971 let Some(table) = self.schema.table(&op.table) else {
7972 continue;
7973 };
7974 let fallback = self.stored_key_fallback(table, &op.row_id);
7975 if let Ok(payload) = encode_sparse_row_json(
7976 table,
7977 &op.row_id,
7978 values,
7979 &self.encryption,
7980 fallback.as_ref(),
7981 ) {
7982 payloads.push(payload);
7983 }
7984 }
7985 }
7986 payloads
7987 }
7988
7989 pub fn conflicts(&self) -> &[ConflictRecord] {
7990 &self.conflicts
7991 }
7992
7993 pub fn rejections(&self) -> &[RejectionRecord] {
7994 &self.rejections
7995 }
7996
7997 pub fn resolve_commit_outcome(
7998 &mut self,
7999 input: ResolveCommitOutcomeInput,
8000 ) -> Result<CommitOutcome, String> {
8001 let current = self
8002 .commit_outcome(&input.client_commit_id)?
8003 .ok_or_else(|| {
8004 format!(
8005 "sync.outcome_not_found: no durable outcome exists for {:?}",
8006 input.client_commit_id
8007 )
8008 })?;
8009 if current.resolution != CommitOutcomeResolution::Active {
8010 return Ok(current);
8011 }
8012 if input.resolution == CommitOutcomeResolution::Active {
8013 return Err("sync.invalid_request: resolution must leave active state".to_owned());
8014 }
8015 match input.resolution {
8016 CommitOutcomeResolution::Superseded => {
8017 let replacement = input
8018 .replacement_client_commit_id
8019 .as_deref()
8020 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
8021 .ok_or_else(|| {
8022 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
8023 .to_owned()
8024 })?;
8025 let _ = replacement;
8026 }
8027 _ if input.replacement_client_commit_id.is_some() => {
8028 return Err(
8029 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
8030 .to_owned(),
8031 );
8032 }
8033 _ => {}
8034 }
8035 let allowed = match current.status {
8036 CommitOutcomeStatus::Conflict => matches!(
8037 input.resolution,
8038 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
8039 ),
8040 CommitOutcomeStatus::Rejected => {
8041 input.resolution == CommitOutcomeResolution::Superseded
8042 }
8043 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
8044 input.resolution == CommitOutcomeResolution::Dismissed
8045 }
8046 };
8047 if !allowed {
8048 return Err(format!(
8049 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
8050 input.resolution, current.status
8051 ));
8052 }
8053
8054 self.begin_observation("syncular_outcome_resolution")?;
8055 let result = (|| {
8056 self.conn
8057 .execute(
8058 "UPDATE _syncular_commit_outcomes
8059 SET resolution = ?1, resolved_at_ms = ?2,
8060 replacement_client_commit_id = ?3
8061 WHERE client_commit_id = ?4 AND resolution = 'active'",
8062 rusqlite::params![
8063 Self::outcome_resolution_name(input.resolution),
8064 self.clock_now_ms(),
8065 input.replacement_client_commit_id,
8066 input.client_commit_id
8067 ],
8068 )
8069 .map_err(|error| error.to_string())?;
8070 let resolved = self
8071 .commit_outcome(¤t.client_commit_id)?
8072 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
8073 self.prune_commit_outcomes()?;
8074 Ok(resolved)
8075 })();
8076 let resolved = match result {
8077 Ok(outcome) => outcome,
8078 Err(error) => {
8079 self.rollback_observation("syncular_outcome_resolution");
8080 return Err(error);
8081 }
8082 };
8083 self.conflicts
8084 .retain(|record| record.client_commit_id != current.client_commit_id);
8085 self.rejections
8086 .retain(|record| record.client_commit_id != current.client_commit_id);
8087 let batch = ChangeAccumulator {
8088 conflicts: current.status == CommitOutcomeStatus::Conflict,
8089 rejections: current.status == CommitOutcomeStatus::Rejected,
8090 outcomes: true,
8091 ..ChangeAccumulator::default()
8092 };
8093 if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
8094 self.rollback_observation("syncular_outcome_resolution");
8095 return Err(error);
8096 }
8097 Ok(resolved)
8098 }
8099
8100 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
8101 self.schema_floor.as_ref()
8102 }
8103
8104 pub fn lease_state(&self) -> Option<&LeaseState> {
8106 self.lease_state.as_ref()
8107 }
8108
8109 fn record_lease_error(&mut self, code: &str) {
8112 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
8113 return;
8114 }
8115 let mut next = self.lease_state.clone().unwrap_or_default();
8116 next.error_code = Some(code.to_owned());
8117 self.set_lease_state(Some(next));
8118 }
8119
8120 fn set_lease_state(&mut self, next: Option<LeaseState>) {
8121 if self.lease_state == next {
8122 return;
8123 }
8124 if self.begin_observation("syncular_lease").is_err() {
8125 return;
8126 }
8127 self.lease_state = next;
8128 if let Some(lease) = &self.lease_state {
8129 if let Ok(json) = serde_json::to_string(lease) {
8130 self.set_meta(LEASE_STATE_KEY, &json);
8131 }
8132 } else {
8133 self.delete_meta(LEASE_STATE_KEY);
8134 }
8135 let batch = ChangeAccumulator {
8136 status: true,
8137 ..ChangeAccumulator::default()
8138 };
8139 if self.finish_observation("syncular_lease", batch).is_err() {
8140 self.rollback_observation("syncular_lease");
8141 }
8142 }
8143
8144 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
8145 if self.schema_floor == next {
8146 return;
8147 }
8148 if self.begin_observation("syncular_schema_floor").is_err() {
8149 return;
8150 }
8151 self.schema_floor = next;
8152 self.stopped = self.schema_floor.is_some();
8153 if let Some(floor) = &self.schema_floor {
8154 if let Ok(json) = serde_json::to_string(floor) {
8155 self.set_meta(SCHEMA_FLOOR_KEY, &json);
8156 }
8157 } else {
8158 self.delete_meta(SCHEMA_FLOOR_KEY);
8159 }
8160 let batch = ChangeAccumulator {
8161 status: true,
8162 ..ChangeAccumulator::default()
8163 };
8164 if self
8165 .finish_observation("syncular_schema_floor", batch)
8166 .is_err()
8167 {
8168 self.rollback_observation("syncular_schema_floor");
8169 }
8170 }
8171
8172 fn set_upgrading(&mut self, value: bool) {
8173 if self.upgrading == value {
8174 return;
8175 }
8176 if self.begin_observation("syncular_upgrading").is_err() {
8177 return;
8178 }
8179 self.upgrading = value;
8180 let batch = ChangeAccumulator {
8181 status: true,
8182 ..ChangeAccumulator::default()
8183 };
8184 if self
8185 .finish_observation("syncular_upgrading", batch)
8186 .is_err()
8187 {
8188 self.rollback_observation("syncular_upgrading");
8189 }
8190 }
8191
8192 pub fn sync_needed(&self) -> bool {
8193 self.sync_needed
8194 }
8195
8196 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
8197 let sub = self.subs.iter().find(|s| s.id == id)?;
8198 Some(SubscriptionStateView {
8199 id: sub.id.clone(),
8200 table: sub.table.clone(),
8201 status: sub.state.name().to_owned(),
8202 cursor: sub.cursor,
8203 has_resume_token: sub.bootstrap_state.is_some(),
8204 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
8205 reason_code: sub.reason_code.clone(),
8206 })
8207 }
8208
8209 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
8210 let schema_table = self
8211 .schema
8212 .table(table)
8213 .ok_or_else(|| format!("unknown table {table:?}"))?;
8214 let sql = format!(
8215 "SELECT * FROM {} ORDER BY {} ASC",
8216 visible_table(table),
8217 quote_ident(&schema_table.primary_key)
8218 );
8219 let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
8220 let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
8221 let mut out = Vec::new();
8222 while let Some(row) = rows.next().map_err(|e| e.to_string())? {
8223 let mut values = Map::new();
8224 for (i, column) in schema_table.columns.iter().enumerate() {
8225 let value = row.get_ref(i).map_err(|e| e.to_string())?;
8226 values.insert(column.name.clone(), sql_ref_to_json(column, value));
8227 }
8228 let version: i64 = row
8229 .get(schema_table.columns.len())
8230 .map_err(|e| e.to_string())?;
8231 let row_id = match values.get(&schema_table.primary_key) {
8232 Some(Value::String(s)) => s.clone(),
8233 Some(Value::Number(n)) => n.to_string(),
8234 Some(Value::Bool(b)) => b.to_string(),
8235 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
8236 };
8237 out.push(RowState {
8238 row_id,
8239 version,
8240 values,
8241 });
8242 }
8243 Ok(out)
8244 }
8245
8246 #[cfg(feature = "crdt-yjs")]
8262 fn crdt_column_bytes(
8263 &self,
8264 table: &str,
8265 row_id: &str,
8266 column: &str,
8267 ) -> Result<Option<Vec<u8>>, String> {
8268 let schema_table = self
8269 .schema
8270 .table(table)
8271 .ok_or_else(|| format!("unknown table {table:?}"))?;
8272 let col = schema_table
8273 .columns
8274 .iter()
8275 .find(|c| c.name == column)
8276 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
8277 if col.ty != ColumnType::Crdt {
8278 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
8279 }
8280 let sql = format!(
8281 "SELECT {} FROM {} WHERE {}",
8282 quote_ident(column),
8283 visible_table(table),
8284 row_id_predicate(schema_table)
8285 );
8286 let bytes: Option<Vec<u8>> = self
8287 .conn
8288 .prepare_cached(&sql)
8289 .map_err(|error| error.to_string())?
8290 .query_row(rusqlite::params![row_id], |row| {
8291 row.get::<_, Option<Vec<u8>>>(0)
8292 })
8293 .map_err(|e| match e {
8294 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
8295 other => other.to_string(),
8296 })?;
8297 Ok(bytes)
8298 }
8299
8300 #[cfg(feature = "crdt-yjs")]
8304 pub fn crdt_text(
8305 &self,
8306 table: &str,
8307 row_id: &str,
8308 column: &str,
8309 name: &str,
8310 ) -> Result<String, String> {
8311 let bytes = self
8312 .crdt_column_bytes(table, row_id, column)?
8313 .unwrap_or_default();
8314 crate::crdt::text(&bytes, name)
8315 }
8316
8317 #[cfg(feature = "crdt-yjs")]
8321 pub fn crdt_insert_text(
8322 &mut self,
8323 table: &str,
8324 row_id: &str,
8325 column: &str,
8326 name: &str,
8327 index: u32,
8328 value: &str,
8329 ) -> Result<String, String> {
8330 let current = self
8331 .crdt_column_bytes(table, row_id, column)?
8332 .unwrap_or_default();
8333 let update = crate::crdt::insert_text(¤t, name, index, value)?;
8334 self.crdt_push_update(table, row_id, column, &update)
8335 }
8336
8337 #[cfg(feature = "crdt-yjs")]
8341 pub fn crdt_delete_text(
8342 &mut self,
8343 table: &str,
8344 row_id: &str,
8345 column: &str,
8346 name: &str,
8347 index: u32,
8348 len: u32,
8349 ) -> Result<String, String> {
8350 let current = self
8351 .crdt_column_bytes(table, row_id, column)?
8352 .unwrap_or_default();
8353 let update = crate::crdt::delete_text(¤t, name, index, len)?;
8354 self.crdt_push_update(table, row_id, column, &update)
8355 }
8356
8357 #[cfg(feature = "crdt-yjs")]
8362 pub fn crdt_apply_update(
8363 &mut self,
8364 table: &str,
8365 row_id: &str,
8366 column: &str,
8367 update: &[u8],
8368 ) -> Result<String, String> {
8369 let current = self
8370 .crdt_column_bytes(table, row_id, column)?
8371 .unwrap_or_default();
8372 let next = crate::crdt::apply_update(¤t, update)?;
8373 self.crdt_push_update(table, row_id, column, &next)
8374 }
8375
8376 #[cfg(feature = "crdt-yjs")]
8383 fn crdt_push_update(
8384 &mut self,
8385 table: &str,
8386 row_id: &str,
8387 column: &str,
8388 crdt_bytes: &[u8],
8389 ) -> Result<String, String> {
8390 let mut bytes_obj = Map::new();
8391 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
8392 let mut partial = Map::new();
8393 partial.insert(column.to_owned(), Value::Object(bytes_obj));
8394 self.patch(table, row_id, partial, None)
8395 }
8396
8397 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
8412 query_connection(&self.conn, sql, params)
8413 }
8414
8415 #[cfg(feature = "bench-internals")]
8417 #[doc(hidden)]
8418 pub fn benchmark_connection(&mut self) -> &mut Connection {
8419 &mut self.conn
8420 }
8421
8422 #[cfg(feature = "bench-internals")]
8424 #[doc(hidden)]
8425 pub fn benchmark_phases(
8426 &self,
8427 enabled: Option<bool>,
8428 reset: bool,
8429 ) -> Result<Option<Value>, String> {
8430 self.benchmark_phases.configure(enabled, reset)?;
8431 Ok(self.benchmark_phases.snapshot())
8432 }
8433
8434 pub fn query_snapshot(
8436 &mut self,
8437 sql: &str,
8438 params: &[QueryValue],
8439 coverage: &[WindowCoverage],
8440 ) -> Result<QuerySnapshot, String> {
8441 snapshot_connection(&self.conn, sql, params, coverage)
8442 }
8443
8444 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
8447 #[cfg(feature = "bench-internals")]
8448 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
8449 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
8450 let mut frames = vec![Frame::ReqHeader {
8451 client_id: self.client_id.clone(),
8452 schema_version: self.schema.version,
8453 log_epoch: log_epoch.clone(),
8454 }];
8455 let mut pushed_ids = Vec::new();
8456 let mut ops_in_request = 0usize;
8457 let mut deferred_commits = 0usize;
8458 #[cfg(feature = "bench-internals")]
8459 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
8460 for (index, commit) in self
8461 .outbox
8462 .iter()
8463 .take(if log_epoch.is_some() {
8464 self.outbox.len()
8465 } else {
8466 0
8467 })
8468 .enumerate()
8469 {
8470 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
8475 deferred_commits = self.outbox.len() - index;
8476 break;
8477 }
8478 ops_in_request += commit.ops.len();
8479 let operations = commit
8480 .ops
8481 .iter()
8482 .map(|op| {
8483 let payload = op.values.as_ref().and_then(|values| {
8484 let table = self.schema.table(&op.table)?;
8485 let fallback = self.stored_key_fallback(table, &op.row_id);
8494 encode_sparse_row_json(
8495 table,
8496 &op.row_id,
8497 values,
8498 &self.encryption,
8499 fallback.as_ref(),
8500 )
8501 .ok()
8502 });
8503 ssp2::model::Operation {
8504 table: op.table.clone(),
8505 row_id: op.row_id.clone(),
8506 op: if op.upsert { Op::Upsert } else { Op::Delete },
8507 base_version: op.base_version,
8508 payload,
8509 }
8510 })
8511 .collect();
8512 frames.push(Frame::PushCommit {
8513 client_commit_id: commit.client_commit_id.clone(),
8514 operations,
8515 });
8516 pushed_ids.push(commit.client_commit_id.clone());
8517 }
8518 #[cfg(feature = "bench-internals")]
8519 drop(outbox_phase);
8520 let accept = self.limits.accept.unwrap_or(if url_capable {
8523 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
8524 } else {
8525 DEFAULT_ACCEPT
8526 });
8527 frames.push(Frame::PullHeader {
8528 limit_commits: self.limits.limit_commits.unwrap_or(0),
8529 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
8530 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
8531 accept,
8532 });
8533 let mut fresh = Vec::new();
8534 for sub in &self.subs {
8535 if sub.state != SubState::Active {
8536 continue;
8537 }
8538 let mut scopes = sub.requested.clone();
8539 sort_scope_map(&mut scopes);
8540 frames.push(Frame::Subscription {
8541 id: sub.id.clone(),
8542 table: sub.table.clone(),
8543 scopes,
8544 params: sub.params.clone().map(RawJson),
8545 cursor: sub.cursor,
8546 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
8547 });
8548 fresh.push((
8549 sub.id.clone(),
8550 sub.cursor < 0 && sub.bootstrap_state.is_none(),
8551 ));
8552 }
8553 let message = Message {
8554 wire_version: WIRE_VERSION,
8555 msg_kind: MsgKind::Request,
8556 frames,
8557 };
8558 (
8559 message,
8560 RequestMeta {
8561 pushed_ids,
8562 fresh,
8563 accept,
8564 deferred_commits,
8565 },
8566 )
8567 }
8568
8569 pub fn progress(&self) -> ProgressObserver {
8573 self.progress.clone()
8574 }
8575
8576 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
8577 self.progress.start();
8578 let started_at_ms = self.clock_now_ms();
8579 let outcome = self.sync_inner(transport);
8580 self.progress.update(|p| match &outcome {
8581 SyncOutcome::Ok(report) if report.failed.is_empty() => {
8582 p.state = ProgressState::Complete
8583 }
8584 SyncOutcome::Ok(_) => {
8585 p.state = ProgressState::Failed;
8586 p.error_code = Some("sync.scope_revoked".into());
8587 }
8588 SyncOutcome::Failed { error_code, .. } => {
8589 p.state = ProgressState::Failed;
8590 p.error_code = Some(Self::diagnostic_code(error_code));
8591 }
8592 });
8593 let completed_at_ms = self.clock_now_ms();
8594 self.last_round = Some(match &outcome {
8595 SyncOutcome::Ok(report) => DiagnosticLastRound {
8596 status: "succeeded".to_owned(),
8597 started_at_ms,
8598 completed_at_ms,
8599 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
8600 counters: Some(DiagnosticRoundCounters {
8601 pushed: report.pushed,
8602 applied: report.applied.len(),
8603 rejected: report.rejected.len(),
8604 retryable: report.retryable.len(),
8605 conflicts: report.conflicts,
8606 commits_applied: report.commits_applied,
8607 segment_rows_applied: report.segment_rows_applied,
8608 bootstrapping: report.bootstrapping.len(),
8609 resets: report.resets.len(),
8610 revoked: report.revoked.len(),
8611 failed: report.failed.len(),
8612 deferred_commits: report.deferred_commits,
8613 }),
8614 error_code: None,
8615 },
8616 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
8617 status: "failed".to_owned(),
8618 started_at_ms,
8619 completed_at_ms,
8620 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
8621 counters: None,
8622 error_code: Some(Self::diagnostic_code(error_code)),
8623 },
8624 });
8625 outcome
8626 }
8627
8628 fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
8629 if self.stopped {
8630 return SyncOutcome::Ok(SyncReport {
8633 schema_floor: self.schema_floor.clone(),
8634 ..SyncReport::default()
8635 });
8636 }
8637 self.set_sync_needed(false, false);
8640 if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
8643 if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
8644 if Self::retryable_transport_code(&code) {
8645 self.schedule_background_retry();
8646 }
8647 return SyncOutcome::Failed {
8648 error_code: code,
8649 message,
8650 };
8651 }
8652 }
8653 if let Err(message) = self.drain_pending_evictions() {
8654 return SyncOutcome::Failed {
8655 error_code: "storage.failed".into(),
8656 message,
8657 };
8658 }
8659 if let Err(message) = self.drop_unencodable_outbox() {
8663 return SyncOutcome::Failed {
8664 error_code: "storage.failed".into(),
8665 message,
8666 };
8667 }
8668 let (message, meta) = self.build_request(transport.supports_url_fetch());
8669 let request_bytes = {
8670 #[cfg(feature = "bench-internals")]
8671 let _phase = self.benchmark_phases.start(Phase::RequestEncode);
8672 encode_message(&message)
8673 };
8674 let round = if self.realtime_connected {
8678 transport.realtime_sync(&request_bytes)
8679 } else {
8680 transport.sync(&request_bytes)
8681 };
8682 let response_bytes = match round {
8683 Ok(bytes) => bytes,
8684 Err(TransportError { code, message }) => {
8685 self.record_lease_error(&code);
8688 if Self::retryable_transport_code(&code) {
8689 self.schedule_background_retry();
8690 }
8691 return SyncOutcome::Failed {
8692 error_code: code,
8693 message,
8694 };
8695 }
8696 };
8697 #[cfg(feature = "bench-internals")]
8698 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
8699 let response = match decode_message(&response_bytes) {
8700 Ok(message) => message,
8701 Err(error) => {
8702 return SyncOutcome::Failed {
8705 error_code: error.code.as_str().to_owned(),
8706 message: error.detail,
8707 };
8708 }
8709 };
8710 #[cfg(feature = "bench-internals")]
8711 drop(decode_phase);
8712 if response.msg_kind != MsgKind::Response {
8713 return SyncOutcome::Failed {
8714 error_code: "sync.invalid_request".to_owned(),
8715 message: "expected a response message".to_owned(),
8716 };
8717 }
8718 let mut outcome = self.process_response(transport, response, &meta);
8719 if let SyncOutcome::Ok(report) = &mut outcome {
8720 report.deferred_commits = meta.deferred_commits;
8721 }
8722 match &outcome {
8723 SyncOutcome::Ok(_) => self.reset_background_retry(),
8724 SyncOutcome::Failed { error_code, .. }
8725 if Self::retryable_transport_code(error_code) =>
8726 {
8727 self.schedule_background_retry();
8728 }
8729 SyncOutcome::Failed { .. } => {}
8730 }
8731 if meta.deferred_commits > 0 {
8732 self.set_sync_needed(true, true);
8735 }
8736 self.previous_version_lifetime_check();
8739 outcome
8740 }
8741
8742 pub fn sync_until_idle(
8743 &mut self,
8744 transport: &mut dyn Transport,
8745 max_rounds: Option<u32>,
8746 ) -> SyncOutcome {
8747 let rounds = max_rounds.unwrap_or(20).max(1);
8748 let mut aggregate = SyncReport::default();
8749 let mut spent = 0;
8750 while spent < rounds {
8751 spent += 1;
8752 let before = self
8753 .subs
8754 .iter()
8755 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
8756 .collect::<Vec<_>>();
8757 match self.sync(transport) {
8758 SyncOutcome::Failed {
8759 error_code,
8760 message,
8761 } => {
8762 return SyncOutcome::Failed {
8763 error_code,
8764 message,
8765 };
8766 }
8767 SyncOutcome::Ok(report) => {
8768 if max_rounds.is_none()
8769 && report.segment_rows_applied > 0
8770 && !report.bootstrapping.is_empty()
8771 && before
8772 != self
8773 .subs
8774 .iter()
8775 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
8776 .collect::<Vec<_>>()
8777 {
8778 spent = 0;
8779 }
8780 aggregate.pushed += report.pushed;
8781 aggregate.applied.extend(report.applied.iter().cloned());
8782 aggregate.rejected.extend(report.rejected.iter().cloned());
8783 aggregate.retryable.extend(report.retryable.iter().cloned());
8784 aggregate.conflicts += report.conflicts;
8785 aggregate.commits_applied += report.commits_applied;
8786 aggregate.segment_rows_applied += report.segment_rows_applied;
8787 aggregate.bootstrapping = report.bootstrapping.clone();
8788 aggregate.resets.extend(report.resets.iter().cloned());
8789 aggregate.revoked.extend(report.revoked.iter().cloned());
8790 aggregate.failed.extend(report.failed.iter().cloned());
8791 aggregate.deferred_commits = report.deferred_commits;
8792 if report.schema_floor.is_some() {
8793 aggregate.schema_floor = report.schema_floor.clone();
8794 }
8795 let more = !report.bootstrapping.is_empty()
8801 || report.commits_applied > 0
8802 || report.segment_rows_applied > 0
8803 || !report.resets.is_empty()
8804 || self.sync_needed;
8805 if !more {
8806 return SyncOutcome::Ok(aggregate);
8807 }
8808 }
8809 }
8810 }
8811 SyncOutcome::Failed {
8812 error_code: "sync.invalid_request".into(),
8813 message: "sync did not reach idle within the round budget".into(),
8814 }
8815 }
8816
8817 fn process_response(
8818 &mut self,
8819 transport: &mut dyn Transport,
8820 response: Message,
8821 meta: &RequestMeta,
8822 ) -> SyncOutcome {
8823 #[cfg(feature = "bench-internals")]
8824 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
8825 let mut report = SyncReport {
8826 pushed: meta.pushed_ids.len() as u32,
8827 ..SyncReport::default()
8828 };
8829 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
8830 HashMap::new();
8831 let pushed_ids = meta
8832 .pushed_ids
8833 .iter()
8834 .map(String::as_str)
8835 .collect::<HashSet<_>>();
8836 let mut last_final_push_result_id: Option<String> = None;
8837 for frame in &response.frames {
8838 match frame {
8839 Frame::PushResultDetails {
8840 client_commit_id,
8841 entries,
8842 } => {
8843 let details = rejection_details_by_commit
8844 .entry(client_commit_id.clone())
8845 .or_default();
8846 for entry in entries {
8847 let parsed = match RejectionDetails::parse(&entry.details.0) {
8848 Ok(value) => value,
8849 Err(message) => {
8850 return SyncOutcome::Failed {
8851 error_code: "sync.invalid_request".to_owned(),
8852 message,
8853 };
8854 }
8855 };
8856 details.insert(entry.op_index, parsed);
8857 }
8858 }
8859 Frame::PushResult {
8860 client_commit_id,
8861 status,
8862 results,
8863 ..
8864 } if pushed_ids.contains(client_commit_id.as_str())
8865 && Self::push_result_is_final(*status, results) =>
8866 {
8867 last_final_push_result_id = Some(client_commit_id.clone());
8868 }
8869 _ => {}
8870 }
8871 }
8872 let mut frames = response.frames.into_iter().peekable();
8873 if response.wire_version < 2 {
8874 return SyncOutcome::Failed {
8875 error_code: "client.invalid_host_response".to_owned(),
8876 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
8877 };
8878 }
8879 match frames.next() {
8880 Some(Frame::RespHeader {
8881 required_schema_version,
8882 latest_schema_version,
8883 log_epoch,
8884 reset_required,
8885 }) => {
8886 let Some(log_epoch) = log_epoch else {
8887 return SyncOutcome::Failed {
8888 error_code: "client.invalid_host_response".to_owned(),
8889 message: "response header omits logEpoch".to_owned(),
8890 };
8891 };
8892 let Some(reset_required) = reset_required else {
8893 return SyncOutcome::Failed {
8894 error_code: "client.invalid_host_response".to_owned(),
8895 message: "response header omits resetRequired".to_owned(),
8896 };
8897 };
8898 if let Some(required) = required_schema_version {
8899 let floor = SchemaFloor {
8902 required_schema_version: Some(required),
8903 latest_schema_version,
8904 };
8905 self.set_schema_floor(Some(floor.clone()));
8906 report.schema_floor = Some(floor);
8907 return SyncOutcome::Ok(report);
8908 }
8909 if reset_required {
8910 if frames.next().is_some() {
8911 return SyncOutcome::Failed {
8912 error_code: "client.invalid_host_response".to_owned(),
8913 message: "log-epoch reset response contains body frames".to_owned(),
8914 };
8915 }
8916 match self.run_log_epoch_reset(&log_epoch) {
8917 Ok(resets) => {
8918 report.resets = resets;
8919 return SyncOutcome::Ok(report);
8920 }
8921 Err(message) => {
8922 return SyncOutcome::Failed {
8923 error_code: "sync.local_corrupt".to_owned(),
8924 message,
8925 };
8926 }
8927 }
8928 }
8929 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
8930 return SyncOutcome::Failed {
8931 error_code: "client.invalid_host_response".to_owned(),
8932 message: "server changed logEpoch without requiring a reset".to_owned(),
8933 };
8934 }
8935 }
8936 _ => {
8937 return SyncOutcome::Failed {
8938 error_code: "sync.invalid_request".to_owned(),
8939 message: "response does not start with RESP_HEADER".to_owned(),
8940 };
8941 }
8942 }
8943
8944 let mut failure: Option<(String, String)> = None;
8945 while let Some(frame) = frames.next() {
8946 match frame {
8947 frame @ Frame::PushResult { .. } => {
8948 let successful = matches!(
8949 &frame,
8950 Frame::PushResult {
8951 status: PushStatus::Applied | PushStatus::Cached,
8952 ..
8953 }
8954 );
8955 let mut results = vec![frame];
8956 if successful {
8957 while matches!(
8958 frames.peek(),
8959 Some(Frame::PushResult {
8960 status: PushStatus::Applied | PushStatus::Cached,
8961 ..
8962 })
8963 ) {
8964 if let Some(next) = frames.next() {
8965 results.push(next);
8966 }
8967 }
8968 }
8969 if self
8970 .handle_push_results(
8971 &results,
8972 &pushed_ids,
8973 &rejection_details_by_commit,
8974 &mut report,
8975 last_final_push_result_id.as_deref(),
8976 )
8977 .is_err()
8978 {
8979 failure = Some((
8980 "client.outcome_persistence_failed".to_owned(),
8981 "local commit outcome could not be persisted".to_owned(),
8982 ));
8983 break;
8984 }
8985 }
8986 Frame::PushResultDetails { .. } => {}
8987 Frame::SubStart {
8988 id,
8989 status,
8990 reason_code,
8991 effective_scopes,
8992 bootstrap: _,
8993 } => {
8994 let mut body = Vec::new();
8995 let mut sub_end: Option<(i64, Option<String>)> = None;
8996 for inner in frames.by_ref() {
8997 match inner {
8998 Frame::SubEnd {
8999 next_cursor,
9000 bootstrap_state,
9001 } => {
9002 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
9003 break;
9004 }
9005 Frame::Unknown { .. } => {}
9006 other => body.push(other),
9007 }
9008 }
9009 if let Err(SectionError::Abort(code, message)) = self.process_section(
9010 transport,
9011 &id,
9012 status,
9013 &reason_code,
9014 effective_scopes,
9015 body,
9016 sub_end,
9017 meta,
9018 &mut report,
9019 ) {
9020 failure = Some((code, message));
9021 break;
9022 }
9023 }
9024 Frame::Lease {
9025 lease_id,
9026 expires_at_ms,
9027 } => {
9028 self.set_lease_state(Some(LeaseState {
9031 lease_id: Some(lease_id),
9032 expires_at_ms: Some(expires_at_ms),
9033 error_code: None,
9034 }));
9035 }
9036 Frame::Error { code, message, .. } => {
9037 failure = Some((code, message));
9040 break;
9041 }
9042 Frame::Unknown { .. } => {}
9043 _ => {
9044 failure = Some((
9045 "sync.invalid_request".to_owned(),
9046 "unexpected frame in response".to_owned(),
9047 ));
9048 break;
9049 }
9050 }
9051 }
9052
9053 if self.overlay_dirty.get() {
9058 self.rebuild_overlay();
9059 }
9060 if let Some((error_code, message)) = failure {
9061 return SyncOutcome::Failed {
9062 error_code,
9063 message,
9064 };
9065 }
9066 if let Err(message) = self.drain_pending_evictions() {
9069 return SyncOutcome::Failed {
9070 error_code: "storage.failed".into(),
9071 message,
9072 };
9073 }
9074 self.ack_after_pull(transport);
9075 if self.upgrading && report.bootstrapping.is_empty() {
9078 self.set_upgrading(false);
9079 }
9080 SyncOutcome::Ok(report)
9081 }
9082
9083 fn handle_push_results(
9086 &mut self,
9087 frames: &[Frame],
9088 pushed_ids: &HashSet<&str>,
9089 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
9090 report: &mut SyncReport,
9091 last_final_push_result_id: Option<&str>,
9092 ) -> Result<(), String> {
9093 self.begin_observation("syncular_push_results")?;
9094 let conflict_count = self.conflicts.len();
9095 let rejection_count = self.rejections.len();
9096 let applied_count = report.applied.len();
9097 let rejected_count = report.rejected.len();
9098 let retryable_count = report.retryable.len();
9099 let was_dirty = self.overlay_dirty.get();
9100 let mut removed = Vec::new();
9101 let mut batch = ChangeAccumulator::default();
9102 let persisted = (|| {
9103 for frame in frames {
9104 let Frame::PushResult {
9105 client_commit_id,
9106 status,
9107 results,
9108 ..
9109 } = frame
9110 else {
9111 return Err("unexpected acknowledgement frame".to_owned());
9112 };
9113 if !pushed_ids.contains(client_commit_id.as_str()) {
9114 continue;
9115 }
9116 let Some(index) = self
9117 .outbox
9118 .iter()
9119 .position(|commit| commit.client_commit_id == *client_commit_id)
9120 else {
9121 continue;
9122 };
9123 let status = *status;
9124 let rejection_details = rejection_details_by_commit.get(client_commit_id);
9125 let operations = self.outbox[index].ops.clone();
9126 match status {
9127 PushStatus::Applied | PushStatus::Cached => {
9128 let journal_results = results
9131 .iter()
9132 .map(|result| {
9133 let op_index = match result {
9134 OpResult::Applied { op_index }
9135 | OpResult::Conflict { op_index, .. }
9136 | OpResult::Error { op_index, .. } => *op_index,
9137 };
9138 CommitOperationOutcome::Applied { op_index }
9139 })
9140 .collect::<Vec<_>>();
9141 let outcome_status = if status == PushStatus::Applied {
9142 CommitOutcomeStatus::Applied
9143 } else {
9144 CommitOutcomeStatus::Cached
9145 };
9146 let persisted = self
9147 .persist_commit_outcome(
9148 client_commit_id,
9149 outcome_status,
9150 &journal_results,
9151 None,
9152 )
9153 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9154 persisted?;
9155 self.overlay_dirty.set(true);
9156 batch.status = true;
9157 batch.outcomes = true;
9158 }
9159 PushStatus::Rejected => {
9160 if results.iter().any(|result| {
9161 matches!(
9162 result,
9163 OpResult::Error {
9164 code,
9165 retryable: true,
9166 ..
9167 } if code == "sync.idempotency_cache_miss"
9168 )
9169 }) {
9170 report.retryable.push(client_commit_id.to_owned());
9173 continue;
9174 }
9175
9176 let mut journal_results = Vec::with_capacity(results.len());
9177 let mut conflicts = Vec::new();
9178 let mut rejections = Vec::new();
9179 for result in results {
9180 match result {
9181 OpResult::Applied { op_index } => {
9182 journal_results.push(CommitOperationOutcome::Applied {
9183 op_index: *op_index,
9184 });
9185 }
9186 OpResult::Conflict {
9187 op_index,
9188 code,
9189 message,
9190 server_version,
9191 server_row,
9192 conflict_columns: conflict_columns_bitmap,
9193 } => {
9194 let operation = operations
9195 .get(*op_index as usize)
9196 .map(CommitOperation::from);
9197 let (table, row_id) = operation
9198 .as_ref()
9199 .map(|op| (op.table.clone(), op.row_id.clone()))
9200 .unwrap_or_default();
9201 let server_row_json = self
9202 .schema
9203 .table(&table)
9204 .and_then(|t| {
9205 decode_row_bytes(t, server_row, &self.encryption)
9206 .ok()
9207 .map(|row| (t, row))
9208 })
9209 .map(|(t, row)| {
9210 let mut map = Map::new();
9211 for (i, column) in t.columns.iter().enumerate() {
9212 map.insert(
9213 column.name.clone(),
9214 column_value_to_json(
9215 row.get(i).unwrap_or(&None),
9216 ),
9217 );
9218 }
9219 map
9220 })
9221 .unwrap_or_default();
9222 let conflict_columns = self
9225 .schema
9226 .table(&table)
9227 .map(|t| {
9228 t.columns
9229 .iter()
9230 .enumerate()
9231 .filter(|(index, _)| {
9232 conflict_columns_bitmap
9233 .get(index / 8)
9234 .is_some_and(|byte| {
9235 byte & (1 << (index % 8)) != 0
9236 })
9237 })
9238 .map(|(_, column)| column.name.clone())
9239 .collect::<Vec<_>>()
9240 })
9241 .unwrap_or_default();
9242 let conflict = ConflictRecord {
9243 client_commit_id: client_commit_id.to_owned(),
9244 op_index: *op_index,
9245 table,
9246 row_id,
9247 code: code.clone(),
9248 message: message.clone(),
9249 server_version: *server_version,
9250 server_row: server_row_json,
9251 conflict_columns,
9252 operation,
9253 };
9254 journal_results.push(CommitOperationOutcome::Conflict {
9255 conflict: conflict.clone(),
9256 });
9257 conflicts.push(conflict);
9258 }
9259 OpResult::Error {
9260 op_index,
9261 code,
9262 message,
9263 retryable,
9264 } => {
9265 let rejection = RejectionRecord {
9266 client_commit_id: client_commit_id.to_owned(),
9267 op_index: *op_index,
9268 code: code.clone(),
9269 message: message.clone(),
9270 retryable: *retryable,
9271 details: rejection_details
9272 .and_then(|details| details.get(op_index))
9273 .cloned(),
9274 operation: operations
9275 .get(*op_index as usize)
9276 .map(CommitOperation::from),
9277 };
9278 journal_results.push(CommitOperationOutcome::Error {
9279 rejection: rejection.clone(),
9280 });
9281 rejections.push(rejection);
9282 }
9283 }
9284 }
9285 let outcome_status = if conflicts.is_empty() {
9286 CommitOutcomeStatus::Rejected
9287 } else {
9288 CommitOutcomeStatus::Conflict
9289 };
9290 let persisted = self
9291 .persist_commit_outcome(
9292 client_commit_id,
9293 outcome_status,
9294 &journal_results,
9295 Some(&operations),
9296 )
9297 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9298 persisted?;
9299 batch.conflicts = !conflicts.is_empty();
9300 batch.rejections = !rejections.is_empty();
9301 batch.status = true;
9302 batch.outcomes = true;
9303 self.conflicts.extend(conflicts);
9304 self.rejections.extend(rejections);
9305 self.overlay_dirty.set(true);
9306 }
9307 }
9308 removed.push((index, self.outbox.remove(index)));
9309 if batch.status {
9310 for operation in &operations {
9311 if !self.record_row_scopes(
9312 &mut batch,
9313 &operation.table,
9314 &operation.row_id,
9315 false,
9316 ) {
9317 batch.table(&operation.table);
9318 }
9319 }
9320 }
9321 match status {
9322 PushStatus::Applied | PushStatus::Cached => {
9323 report.applied.push(client_commit_id.to_owned())
9324 }
9325 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
9326 }
9327 }
9328 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)) {
9329 self.prune_commit_outcomes()?;
9330 }
9331 self.finish_observation("syncular_push_results", batch)
9332 })();
9333 if let Err(error) = persisted {
9334 self.rollback_observation("syncular_push_results");
9335 for (index, commit) in removed.into_iter().rev() {
9336 self.outbox.insert(index, commit);
9337 }
9338 self.conflicts.truncate(conflict_count);
9339 self.rejections.truncate(rejection_count);
9340 self.overlay_dirty.set(was_dirty);
9341 report.applied.truncate(applied_count);
9342 report.rejected.truncate(rejected_count);
9343 report.retryable.truncate(retryable_count);
9344 return Err(error);
9345 }
9346 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
9347 Ok(())
9348 }
9349
9350 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
9351 status != PushStatus::Rejected
9352 || !results.iter().any(|result| {
9353 matches!(
9354 result,
9355 OpResult::Error {
9356 code,
9357 retryable: true,
9358 ..
9359 } if code == "sync.idempotency_cache_miss"
9360 )
9361 })
9362 }
9363
9364 #[allow(clippy::too_many_arguments)]
9367 fn process_section(
9368 &mut self,
9369 transport: &mut dyn Transport,
9370 id: &str,
9371 status: SubStatus,
9372 reason_code: &str,
9373 effective_scopes: Vec<(String, Vec<String>)>,
9374 body: Vec<Frame>,
9375 sub_end: Option<(i64, Option<String>)>,
9376 meta: &RequestMeta,
9377 report: &mut SyncReport,
9378 ) -> Result<(), SectionError> {
9379 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
9380 return Ok(()); };
9382 if status != SubStatus::Active && sub_end.is_none() {
9383 return Err(SectionError::Abort(
9384 "sync.invalid_request".into(),
9385 "subscription section without SUB_END".into(),
9386 ));
9387 }
9388 match status {
9389 SubStatus::Revoked => {
9390 self.begin_observation("syncular_revocation")
9391 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9392 let previous = self.subs[sub_index].clone();
9393 let was_dirty = self.overlay_dirty.get();
9394 let previous_outbox = self.outbox.clone();
9395 let rejection_count = self.rejections.len();
9396 let revoked_count = report.revoked.len();
9397 let failed_count = report.failed.len();
9398 let result =
9399 (|| {
9400 let mut batch = ChangeAccumulator::default();
9401 let registered = self.window_unit_by_sub_id(id);
9402 let (table, effective) = {
9404 let sub = &self.subs[sub_index];
9405 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9406 };
9407 let purged = self.purge_scope_rows(&table, &effective);
9408 match purged {
9409 Ok(()) => {
9410 self.record_scope_map(&mut batch, &table, &effective);
9411 let sub = &mut self.subs[sub_index];
9412 sub.state = SubState::Revoked;
9413 sub.reason_code = Some(if reason_code.is_empty() {
9414 "sync.scope_revoked".to_owned()
9415 } else {
9416 reason_code.to_owned()
9417 });
9418 report.revoked.push(id.to_owned());
9419 let doomed_effective = effective;
9420 let sub_table = table;
9421 self.persist_sub(&self.subs[sub_index].clone()).map_err(
9422 |message| SectionError::Abort("storage.failed".into(), message),
9423 )?;
9424 let dropped = self
9425 .drop_doomed_outbox(&sub_table, &doomed_effective)
9426 .map_err(|message| {
9427 SectionError::Abort("storage.failed".to_owned(), message)
9428 })?;
9429 if dropped {
9430 batch.status = true;
9431 batch.rejections = true;
9432 batch.outcomes = true;
9433 }
9434 self.delete_unreferenced_cached_blobs();
9437 }
9438 Err(()) => {
9439 let sub = &mut self.subs[sub_index];
9442 sub.state = SubState::Failed;
9443 sub.reason_code = Some("sync.scope_revoked".to_owned());
9444 report.failed.push(id.to_owned());
9445 self.persist_sub(&self.subs[sub_index].clone()).map_err(
9446 |message| SectionError::Abort("storage.failed".into(), message),
9447 )?;
9448 }
9449 }
9450 if let Some((base_key, unit)) = registered {
9451 batch.window(&base_key, &self.subs[sub_index].table, &unit);
9452 }
9453 self.rebuild_overlay_if_dirty();
9454 self.finish_observation("syncular_revocation", batch)
9455 .map_err(|message| {
9456 SectionError::Abort("storage.failed".to_owned(), message)
9457 })?;
9458 Ok(())
9459 })();
9460 if result.is_err() {
9461 self.rollback_observation("syncular_revocation");
9462 self.subs[sub_index] = previous;
9463 self.overlay_dirty.set(was_dirty);
9464 self.outbox = previous_outbox;
9465 self.rejections.truncate(rejection_count);
9466 report.revoked.truncate(revoked_count);
9467 report.failed.truncate(failed_count);
9468 }
9469 result
9470 }
9471 SubStatus::Reset => {
9472 self.begin_observation("syncular_reset")
9473 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9474 let previous = self.subs[sub_index].clone();
9475 let was_dirty = self.overlay_dirty.get();
9476 let reset_count = report.resets.len();
9477 let result = (|| {
9478 let mut batch = ChangeAccumulator::default();
9479 let registered = self.window_unit_by_sub_id(id);
9480 let sub = &mut self.subs[sub_index];
9483 sub.cursor = -1;
9484 sub.bootstrap_state = None;
9485 report.resets.push(id.to_owned());
9486 self.persist_sub(&self.subs[sub_index].clone())
9487 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9488 if let Some((base_key, unit)) = registered {
9489 batch.window(&base_key, &self.subs[sub_index].table, &unit);
9490 }
9491 self.finish_observation("syncular_reset", batch)
9492 .map_err(|message| {
9493 SectionError::Abort("storage.failed".to_owned(), message)
9494 })?;
9495 Ok(())
9496 })();
9497 if result.is_err() {
9498 self.rollback_observation("syncular_reset");
9499 self.subs[sub_index] = previous;
9500 self.overlay_dirty.set(was_dirty);
9501 report.resets.truncate(reset_count);
9502 }
9503 result
9504 }
9505 SubStatus::Active => {
9506 let fresh = meta
9507 .fresh
9508 .iter()
9509 .find(|(fid, _)| fid == id)
9510 .map(|(_, fresh)| *fresh)
9511 .unwrap_or(false);
9512 let previous = self.subs[sub_index].clone();
9513 self.subs[sub_index].effective = Some(effective_scopes);
9514 let outcome = self
9515 .apply_section_body(transport, sub_index, body, fresh, meta, report)
9516 .and_then(|()| {
9517 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
9518 SectionError::Abort(
9519 "sync.invalid_request".into(),
9520 "subscription section without SUB_END".into(),
9521 )
9522 })?;
9523 self.apply_sub_end(sub_index, cursor, bootstrap_state)
9524 .map_err(|message| {
9525 SectionError::Abort("storage.failed".into(), message)
9526 })
9527 });
9528 match outcome {
9529 Ok(()) => {
9530 if self.subs[sub_index].bootstrap_state.is_some() {
9531 report.bootstrapping.push(id.to_owned());
9532 }
9533 Ok(())
9534 }
9535 Err(SectionError::FailClosed) => {
9536 self.subs[sub_index] = previous.clone();
9537 self.begin_observation("syncular_section_failure")
9538 .map_err(|message| {
9539 SectionError::Abort("storage.failed".into(), message)
9540 })?;
9541 let persisted = (|| {
9542 let mut batch = ChangeAccumulator::default();
9543 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
9544 batch.window(&base_key, &self.subs[sub_index].table, &unit);
9545 }
9546 self.subs[sub_index].state = SubState::Failed;
9547 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
9548 self.persist_sub(&self.subs[sub_index])?;
9549 self.finish_observation("syncular_section_failure", batch)
9550 })();
9551 if let Err(message) = persisted {
9552 self.rollback_observation("syncular_section_failure");
9553 self.subs[sub_index] = previous;
9554 return Err(SectionError::Abort("storage.failed".into(), message));
9555 }
9556 report.failed.push(id.to_owned());
9557 Ok(())
9558 }
9559 Err(error) => {
9560 self.subs[sub_index] = previous;
9563 Err(error)
9564 }
9565 }
9566 }
9567 }
9568 }
9569
9570 fn apply_sub_end(
9571 &mut self,
9572 sub_index: usize,
9573 cursor: i64,
9574 bootstrap_state: Option<String>,
9575 ) -> Result<(), String> {
9576 #[cfg(feature = "bench-internals")]
9577 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
9578 let previous = self.subs[sub_index].clone();
9579 self.begin_observation("syncular_sub_end")?;
9580 let persisted = (|| {
9581 let mut batch = ChangeAccumulator::default();
9582 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
9583 && cursor >= 0
9584 && bootstrap_state.is_none();
9585 if completed {
9586 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
9587 batch.window(&base_key, &previous.table, &unit);
9588 }
9589 }
9590 let sub = &mut self.subs[sub_index];
9591 sub.cursor = cursor;
9592 sub.bootstrap_state = bootstrap_state;
9593 sub.synced_once = true;
9594 self.persist_sub(&self.subs[sub_index])?;
9595 self.finish_observation("syncular_sub_end", batch)
9596 })();
9597 if persisted.is_err() {
9598 self.rollback_observation("syncular_sub_end");
9599 self.subs[sub_index] = previous;
9600 }
9601 persisted
9602 }
9603
9604 fn apply_section_body(
9605 &mut self,
9606 transport: &mut dyn Transport,
9607 sub_index: usize,
9608 body: Vec<Frame>,
9609 fresh: bool,
9610 meta: &RequestMeta,
9611 report: &mut SyncReport,
9612 ) -> Result<(), SectionError> {
9613 let mut saw_segment = false;
9614 for frame in body {
9615 match frame {
9616 Frame::Commit {
9617 tables, changes, ..
9618 } => {
9619 self.apply_commit_frame(&tables, &changes)
9620 .map_err(|(c, m)| SectionError::Abort(c, m))?;
9621 report.commits_applied += 1;
9622 }
9623 Frame::SegmentInline { payload } => {
9624 let segment = decode_rows_segment(&payload)
9625 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
9626 self.progress.update(|p| {
9627 p.phase = ProgressPhase::Import;
9628 p.subscription_id = Some(self.subs[sub_index].id.clone());
9629 p.table = Some(self.subs[sub_index].table.clone());
9630 p.segment_id = None;
9631 p.bytes_received = payload.len() as u64;
9632 p.bytes_total = Some(payload.len() as u64);
9633 p.rows_processed = 0;
9634 p.rows_total =
9635 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
9636 });
9637 let first = !saw_segment;
9638 saw_segment = true;
9639 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9640 report.segment_rows_applied += applied;
9641 }
9642 Frame::SegmentRef {
9643 segment_id,
9644 byte_length,
9645 media_type,
9646 table,
9647 row_count,
9648 as_of_commit_seq,
9649 scope_digest,
9650 row_cursor,
9651 next_row_cursor,
9652 url,
9653 url_expires_at_ms,
9654 ..
9655 } => {
9656 let advertised = match media_type {
9659 MediaType::Rows => {
9660 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
9661 || meta.accept & ACCEPT_INLINE_ROWS != 0
9662 }
9663 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
9664 };
9665 if !advertised {
9666 return Err(SectionError::Abort(
9667 "sync.invalid_request".to_owned(),
9668 format!(
9669 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
9670 media_type.name()
9671 ),
9672 ));
9673 }
9674 self.progress.update(|p| {
9675 p.phase = ProgressPhase::Download;
9676 p.subscription_id = Some(self.subs[sub_index].id.clone());
9677 p.table = Some(table.clone());
9678 p.segment_id = Some(segment_id.clone());
9679 p.bytes_received = 0;
9680 p.bytes_total = u64::try_from(byte_length).ok();
9681 p.rows_processed = 0;
9682 p.rows_total = u64::try_from(row_count).ok();
9683 });
9684 let observer = self.progress.clone();
9685 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
9686 let bytes = if let Some(url) = url {
9687 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
9692 return Err(SectionError::Abort(
9693 "sync.invalid_request".to_owned(),
9694 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
9695 .to_owned(),
9696 ));
9697 }
9698 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
9700 return Err(SectionError::Abort(
9701 "sync.segment_expired".to_owned(),
9702 format!(
9703 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
9704 ),
9705 ));
9706 }
9707 transport
9708 .fetch_url(&url, &mut on_progress)
9709 .map_err(|e| SectionError::Abort(e.code, e.message))?
9710 } else {
9711 let requested_scopes_json =
9712 canonical_scope_json(&self.subs[sub_index].requested);
9713 transport
9714 .download_segment(
9715 &SegmentRequest {
9716 segment_id: segment_id.clone(),
9717 table,
9718 requested_scopes_json,
9719 },
9720 &mut on_progress,
9721 )
9722 .map_err(|e| SectionError::Abort(e.code, e.message))?
9723 };
9724 on_progress(bytes.len() as u64);
9726 let digest = Sha256::digest(&bytes);
9727 let expected = segment_id
9728 .strip_prefix("sha256:")
9729 .unwrap_or(segment_id.as_str());
9730 if bytes_to_hex(&digest) != expected {
9731 return Err(SectionError::Abort(
9732 "sync.invalid_request".to_owned(),
9733 "segment bytes do not match the content address (§5.1)".to_owned(),
9734 ));
9735 }
9736 self.progress.update(|p| p.phase = ProgressPhase::Import);
9737 if media_type == MediaType::Sqlite {
9738 if row_cursor.is_some() || next_row_cursor.is_some() {
9741 return Err(SectionError::Abort(
9742 "sync.invalid_request".to_owned(),
9743 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
9744 .to_owned(),
9745 ));
9746 }
9747 let first = !saw_segment;
9748 saw_segment = true;
9749 let applied = self.apply_sqlite_segment(
9750 sub_index,
9751 &bytes,
9752 fresh && first,
9753 row_count,
9754 as_of_commit_seq,
9755 &scope_digest,
9756 )?;
9757 report.segment_rows_applied += applied;
9758 } else {
9759 let segment = decode_rows_segment(&bytes).map_err(|e| {
9760 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
9761 })?;
9762 let first = row_cursor.is_none();
9763 saw_segment = true;
9764 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9765 report.segment_rows_applied += applied;
9766 }
9767 }
9768 Frame::Unknown { .. } => {}
9769 Frame::Error { code, message, .. } => {
9770 return Err(SectionError::Abort(code, message))
9771 }
9772 _ => {
9773 return Err(SectionError::Abort(
9774 "sync.invalid_request".to_owned(),
9775 "unexpected frame inside a subscription section".to_owned(),
9776 ));
9777 }
9778 }
9779 }
9780 Ok(())
9781 }
9782
9783 fn apply_commit_frame(
9785 &mut self,
9786 tables: &[String],
9787 changes: &[ssp2::model::Change],
9788 ) -> Result<(), (String, String)> {
9789 #[cfg(feature = "bench-internals")]
9790 let _phase = self.benchmark_phases.start(Phase::CommitApply);
9791 let was_dirty = self.overlay_dirty.get();
9792 self.begin_observation("syncular_commit")
9793 .map_err(|message| ("storage.failed".into(), message))?;
9794 let applied = (|| {
9795 let mut batch = ChangeAccumulator::default();
9796 self.record_commit_changes(&mut batch, tables, changes);
9797 self.apply_commit_changes(tables, changes)?;
9798 self.rebuild_overlay_if_dirty();
9799 self.finish_observation("syncular_commit", batch)
9800 .map_err(|message| ("storage.failed".into(), message))
9801 })();
9802 if applied.is_err() {
9803 self.rollback_observation("syncular_commit");
9804 self.overlay_dirty.set(was_dirty);
9805 }
9806 applied
9807 }
9808
9809 fn apply_commit_changes(
9810 &mut self,
9811 tables: &[String],
9812 changes: &[ssp2::model::Change],
9813 ) -> Result<(), (String, String)> {
9814 let mirror_visible = !self.overlay_dirty.get()
9819 && (self.outbox.is_empty()
9820 || changes.iter().all(|change| {
9821 tables
9822 .get(change.table_index as usize)
9823 .and_then(|name| self.schema.table(name))
9824 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
9825 }));
9826 for change in changes {
9827 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
9828 (
9829 "sync.invalid_request".to_owned(),
9830 "change tableIndex out of range".to_owned(),
9831 )
9832 })?;
9833 let table = self.schema.table(table_name).ok_or_else(|| {
9834 (
9835 "sync.schema_mismatch".to_owned(),
9836 format!("change targets unknown table {table_name:?}"),
9837 )
9838 })?;
9839 match change.op {
9840 Op::Upsert => {
9841 let payload = change.row.as_ref().ok_or_else(|| {
9842 (
9843 "sync.invalid_request".to_owned(),
9844 "upsert change without row payload".to_owned(),
9845 )
9846 })?;
9847 let row = {
9849 #[cfg(feature = "bench-internals")]
9850 let _phase = self.benchmark_phases.start(Phase::RowDecode);
9851 decode_row_bytes(table, payload, &self.encryption)
9852 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
9853 };
9854 let version = change.row_version.unwrap_or(0);
9855 let table_name = table.name.clone();
9856 self.write_base_row(&table_name, &row, version)
9857 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9858 if mirror_visible {
9859 self.write_row(&visible_table(&table_name), &table_name, &row, version)
9860 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9861 }
9862 }
9863 Op::Delete => {
9864 self.delete_base_row(table_name, &change.row_id)
9865 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9866 if mirror_visible {
9867 let sql = format!(
9868 "DELETE FROM {} WHERE {}",
9869 visible_table(table_name),
9870 row_id_predicate(table)
9871 );
9872 self.conn
9873 .prepare_cached(&sql)
9874 .and_then(|mut statement| {
9875 statement.execute(rusqlite::params![change.row_id])
9876 })
9877 .map_err(|error| {
9878 ("sync.invalid_request".to_owned(), error.to_string())
9879 })?;
9880 }
9881 }
9882 }
9883 }
9884 if mirror_visible {
9885 if !self.outbox.is_empty() {
9886 let changed_rows: HashSet<(&str, &str)> = changes
9887 .iter()
9888 .map(|change| {
9889 (
9890 tables[change.table_index as usize].as_str(),
9891 change.row_id.as_str(),
9892 )
9893 })
9894 .collect();
9895 self.apply_outbox_ops(
9896 self.outbox
9897 .iter()
9898 .flat_map(|commit| &commit.ops)
9899 .filter(|op| {
9900 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
9901 }),
9902 );
9903 }
9904 self.overlay_dirty.set(false);
9905 }
9906 Ok(())
9907 }
9908
9909 fn apply_segment(
9914 &mut self,
9915 sub_index: usize,
9916 segment: &RowsSegment,
9917 first_fresh_page: bool,
9918 ) -> Result<u32, SectionError> {
9919 let (sub_table, effective) = {
9920 let sub = &self.subs[sub_index];
9921 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9922 };
9923 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
9924 SectionError::Abort(
9925 "sync.schema_mismatch".to_owned(),
9926 format!("subscription table {sub_table:?} is not in the client schema"),
9927 )
9928 })?;
9929 let matches = segment.table == table.name
9934 && segment.schema_version == self.schema.version
9935 && segment.columns.len() == table.wire_columns.len()
9936 && segment
9937 .columns
9938 .iter()
9939 .zip(table.wire_columns.iter())
9940 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
9941 if !matches {
9942 return Err(SectionError::Abort(
9943 "sync.schema_mismatch".to_owned(),
9944 "segment column table does not match the generated schema (§5.2)".to_owned(),
9945 ));
9946 }
9947 let mut applied = 0u32;
9948 let empty = Vec::new();
9949 let blocks = if segment.blocks.is_empty() {
9950 std::slice::from_ref(&empty)
9951 } else {
9952 &segment.blocks
9953 };
9954 for (index, block) in blocks.iter().enumerate() {
9955 let clear = first_fresh_page && index == 0;
9956 if !clear && block.is_empty() {
9957 continue;
9958 }
9959 let was_dirty = self.overlay_dirty.get();
9960 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
9961 && self
9962 .outbox
9963 .iter()
9964 .flat_map(|commit| &commit.ops)
9965 .any(|op| op.table == table.name);
9966 let mirror_visible = !reconcile_pending;
9967 self.begin_observation("syncular_segment_block")
9968 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9969 let outcome = (|| {
9970 self.rebuild_overlay_if_dirty();
9971 let mut changed_keys = Vec::new();
9972 let mut batch = ChangeAccumulator::default();
9973 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
9974 batch.table(&table.name);
9975 }
9976 if clear {
9977 self.purge_scope_rows(&table.name, &effective)
9978 .map_err(|()| SectionError::FailClosed)?;
9979 }
9980 for (row_index, row) in block.iter().enumerate() {
9981 let decrypted;
9982 let values = if table.has_encrypted_columns() {
9983 let mut values = row.values.clone();
9984 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
9985 .map_err(|message| {
9986 SectionError::Abort("client.decrypt_failed".into(), message)
9987 })?;
9988 decrypted = values;
9989 &decrypted
9990 } else {
9991 &row.values
9992 };
9993 self.write_base_row(&table.name, values, row.server_version)
9994 .map_err(|message| {
9995 SectionError::Abort("sync.invalid_request".into(), message)
9996 })?;
9997 if reconcile_pending {
9998 changed_keys.push(
9999 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
10000 |message| SectionError::Abort("storage.failed".into(), message),
10001 )?,
10002 );
10003 }
10004 if mirror_visible {
10005 self.write_row(
10006 &visible_table(&table.name),
10007 &table.name,
10008 values,
10009 row.server_version,
10010 )
10011 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10012 }
10013 let processed = u64::from(applied) + row_index as u64 + 1;
10014 if processed.is_multiple_of(1024) {
10015 self.progress.update(|p| p.rows_processed = processed);
10016 }
10017 }
10018 if reconcile_pending {
10019 self.reconcile_imported_rows(&table, &mut changed_keys)
10020 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10021 }
10022 self.apply_outbox_ops(
10023 self.outbox
10024 .iter()
10025 .flat_map(|commit| &commit.ops)
10026 .filter(|op| op.table == table.name),
10027 );
10028 self.overlay_dirty.set(false);
10029 self.finish_observation("syncular_segment_block", batch)
10030 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
10031 })();
10032 if let Err(error) = outcome {
10033 self.rollback_observation("syncular_segment_block");
10034 self.overlay_dirty.set(was_dirty);
10035 return Err(error);
10036 }
10037 applied += block.len() as u32;
10038 self.progress
10039 .update(|p| p.rows_processed = u64::from(applied));
10040 std::thread::yield_now();
10041 }
10042 self.progress
10043 .update(|p| p.rows_processed = u64::from(applied));
10044 Ok(applied)
10045 }
10046
10047 fn apply_sqlite_segment(
10055 &mut self,
10056 sub_index: usize,
10057 bytes: &[u8],
10058 first_fresh_page: bool,
10059 row_count: i64,
10060 as_of_commit_seq: i64,
10061 scope_digest: &str,
10062 ) -> Result<u32, SectionError> {
10063 let invalid = |detail: &str| {
10064 SectionError::Abort(
10065 "sync.invalid_request".to_owned(),
10066 format!("sqlite segment rejected: {detail} (§5.3)"),
10067 )
10068 };
10069 let (sub_table, effective) = {
10070 let sub = &self.subs[sub_index];
10071 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10072 };
10073 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10074 SectionError::Abort(
10075 "sync.schema_mismatch".to_owned(),
10076 format!("subscription table {sub_table:?} is not in the client schema"),
10077 )
10078 })?;
10079
10080 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
10081 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
10082 let img = match rusqlite::Connection::open_with_flags(
10083 &path,
10084 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
10085 ) {
10086 Ok(conn) => conn,
10087 Err(_) => {
10088 let _ = std::fs::remove_file(&path);
10089 return Err(invalid("bytes do not open as a SQLite database"));
10090 }
10091 };
10092 let outcome = self.apply_sqlite_image(
10093 &img,
10094 &table,
10095 first_fresh_page,
10096 &effective,
10097 row_count,
10098 as_of_commit_seq,
10099 scope_digest,
10100 );
10101 drop(img);
10102 let _ = std::fs::remove_file(&path);
10103 outcome
10104 }
10105
10106 #[allow(clippy::too_many_arguments)]
10107 fn apply_sqlite_image(
10108 &mut self,
10109 img: &rusqlite::Connection,
10110 table: &crate::schema::TableSchema,
10111 first_fresh_page: bool,
10112 effective: &[(String, Vec<String>)],
10113 row_count: i64,
10114 as_of_commit_seq: i64,
10115 scope_digest: &str,
10116 ) -> Result<u32, SectionError> {
10117 let invalid = |detail: String| {
10118 SectionError::Abort(
10119 "sync.invalid_request".to_owned(),
10120 format!("sqlite segment rejected: {detail} (§5.3)"),
10121 )
10122 };
10123
10124 type MetaRow = (i64, String, i64, i64, String, i64, i64);
10127 let meta: MetaRow = img
10128 .query_row(
10129 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
10130 \"scopeDigest\", \"rowCount\",
10131 (SELECT count(*) FROM _syncular_segment)
10132 FROM _syncular_segment",
10133 [],
10134 |row| {
10135 Ok((
10136 row.get(0)?,
10137 row.get(1)?,
10138 row.get(2)?,
10139 row.get(3)?,
10140 row.get(4)?,
10141 row.get(5)?,
10142 row.get(6)?,
10143 ))
10144 },
10145 )
10146 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
10147 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
10148 if meta_count != 1 {
10149 return Err(invalid(format!(
10150 "_syncular_segment must contain exactly one row, found {meta_count}"
10151 )));
10152 }
10153 if format != 1 {
10154 return Err(invalid(format!("format {format}")));
10155 }
10156 if meta_table != table.name {
10157 return Err(invalid(format!("image table {meta_table:?}")));
10158 }
10159 if schema_version != i64::from(self.schema.version) {
10160 return Err(invalid(format!("schemaVersion {schema_version}")));
10161 }
10162 if pin != as_of_commit_seq {
10163 return Err(invalid(format!("asOfCommitSeq {pin}")));
10164 }
10165 if digest != scope_digest {
10166 return Err(invalid("scopeDigest mismatch".to_owned()));
10167 }
10168 if meta_rows != row_count {
10169 return Err(invalid(format!("rowCount {meta_rows}")));
10170 }
10171
10172 let mut names: Vec<String> = Vec::new();
10174 let mut primary_keys: Vec<String> = Vec::new();
10175 {
10176 let mut stmt = img
10177 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
10178 .map_err(|_| invalid("image data table missing".to_owned()))?;
10179 let mut rows = stmt
10180 .query([])
10181 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
10182 while let Some(row) = rows
10183 .next()
10184 .map_err(|_| invalid("image data table unreadable".to_owned()))?
10185 {
10186 if row.get::<_, i64>(5).map_err(|e| invalid(e.to_string()))? > 0 {
10187 primary_keys.push(row.get(1).map_err(|e| invalid(e.to_string()))?);
10188 }
10189 names.push(
10190 row.get::<_, String>(1)
10191 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
10192 );
10193 }
10194 }
10195 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
10196 expected.push("_syncular_version");
10197 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
10198 return Err(SectionError::Abort(
10199 "sync.schema_mismatch".to_owned(),
10200 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
10201 ));
10202 }
10203
10204 if primary_keys != vec![table.primary_key.clone()] {
10205 return Err(invalid(
10206 "image primary key does not match generated schema".into(),
10207 ));
10208 }
10209 let actual_count: i64 = img
10210 .query_row(
10211 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
10212 [],
10213 |row| row.get(0),
10214 )
10215 .map_err(|e| invalid(e.to_string()))?;
10216 if actual_count != row_count {
10217 return Err(invalid("image row count does not match descriptor".into()));
10218 }
10219 let insert = self.insert_row_sql(&base_table(&table.name), table);
10220 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
10221 let column_list = names
10222 .iter()
10223 .map(|n| quote_ident(n))
10224 .collect::<Vec<_>>()
10225 .join(", ");
10226 let mut stmt = img
10227 .prepare(&format!(
10228 "SELECT {column_list} FROM {} ORDER BY {}",
10229 quote_ident(&table.name),
10230 quote_ident(&table.primary_key)
10231 ))
10232 .map_err(|e| invalid(e.to_string()))?;
10233 let mut rows = stmt.query([]).map_err(|e| invalid(e.to_string()))?;
10234 let mut applied = 0u32;
10235 loop {
10236 let clear = applied == 0 && first_fresh_page;
10237 let was_dirty = self.overlay_dirty.get();
10238 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10239 && self
10240 .outbox
10241 .iter()
10242 .flat_map(|commit| &commit.ops)
10243 .any(|op| op.table == table.name);
10244 let mirror_visible = !reconcile_pending;
10245 self.begin_observation("syncular_image_chunk")
10246 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10247 let result = (|| {
10248 self.rebuild_overlay_if_dirty();
10249 let mut changed_keys = Vec::new();
10250 let mut batch = ChangeAccumulator::default();
10251 if clear {
10252 if self.scoped_rows_exist(&table.name, effective) {
10253 batch.table(&table.name);
10254 }
10255 self.purge_scope_rows(&table.name, effective)
10256 .map_err(|()| SectionError::FailClosed)?;
10257 }
10258 let mut processed = 0u32;
10259 {
10260 let mut ins = self
10261 .conn
10262 .prepare_cached(&insert)
10263 .map_err(|e| invalid(e.to_string()))?;
10264 let mut mirror = if mirror_visible {
10265 Some(
10266 self.conn
10267 .prepare_cached(&visible_insert)
10268 .map_err(|e| invalid(e.to_string()))?,
10269 )
10270 } else {
10271 None
10272 };
10273 for _ in 0..1024 {
10274 let Some(row) = rows.next().map_err(|e| invalid(e.to_string()))? else {
10275 break;
10276 };
10277 for (i, column) in table.columns.iter().enumerate() {
10278 let cell = row.get_ref(i).map_err(|e| invalid(e.to_string()))?;
10279 let param = image_cell_param(column, cell).map_err(&invalid)?;
10280 ins.raw_bind_parameter(i + 1, ¶m)
10281 .map_err(|e| invalid(e.to_string()))?;
10282 if let Some(mirror) = &mut mirror {
10283 mirror
10284 .raw_bind_parameter(i + 1, ¶m)
10285 .map_err(|e| invalid(e.to_string()))?;
10286 }
10287 }
10288 let version_index = table.columns.len();
10289 let version: i64 =
10290 row.get(version_index).map_err(|e| invalid(e.to_string()))?;
10291 if version < 1 {
10292 return Err(invalid("image row version must be positive".into()));
10293 }
10294 ins.raw_bind_parameter(version_index + 1, version)
10295 .map_err(|e| invalid(e.to_string()))?;
10296 ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
10297 if let Some(mirror) = &mut mirror {
10298 mirror
10299 .raw_bind_parameter(version_index + 1, version)
10300 .map_err(|e| invalid(e.to_string()))?;
10301 mirror.raw_execute().map_err(|e| invalid(e.to_string()))?;
10302 }
10303 if reconcile_pending {
10304 changed_keys.push(
10305 row.get::<_, SqlValue>(table.pk_index)
10306 .map_err(|error| invalid(error.to_string()))?,
10307 );
10308 }
10309 processed += 1;
10310 }
10311 }
10312 if processed > 0 {
10313 batch.table(&table.name);
10314 self.overlay_dirty.set(true);
10315 }
10316 if reconcile_pending {
10317 self.reconcile_imported_rows(table, &mut changed_keys)
10318 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10319 }
10320 self.apply_outbox_ops(
10321 self.outbox
10322 .iter()
10323 .flat_map(|commit| &commit.ops)
10324 .filter(|op| op.table == table.name),
10325 );
10326 self.overlay_dirty.set(false);
10327 self.finish_observation("syncular_image_chunk", batch)
10328 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10329 Ok(processed)
10330 })();
10331 let processed = match result {
10332 Ok(processed) => processed,
10333 Err(error) => {
10334 self.rollback_observation("syncular_image_chunk");
10335 self.overlay_dirty.set(was_dirty);
10336 return Err(error);
10337 }
10338 };
10339 applied += processed;
10340 self.progress
10341 .update(|p| p.rows_processed = u64::from(applied));
10342 std::thread::yield_now();
10343 if i64::from(applied) == actual_count {
10344 break;
10345 }
10346 }
10347 Ok(applied)
10348 }
10349
10350 fn reconcile_imported_rows(
10354 &self,
10355 table: &TableSchema,
10356 keys: &mut Vec<SqlValue>,
10357 ) -> Result<(), String> {
10358 let visible = visible_table(&table.name);
10359 let base = base_table(&table.name);
10360 let pk = quote_ident(&table.primary_key);
10361 let predicate = row_id_predicate(table);
10362 let mut lookup = self.conn.prepare_cached(&format!(
10363 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
10364 )).map_err(|error| error.to_string())?;
10365 let mut deleted = HashSet::new();
10366 for op in self
10367 .outbox
10368 .iter()
10369 .flat_map(|commit| &commit.ops)
10370 .filter(|op| op.table == table.name)
10371 {
10372 if op.upsert {
10373 if let Some(values) = &op.values {
10374 let key = json_to_column_value(
10375 &table.columns[table.pk_index],
10376 values.get(&table.primary_key),
10377 )?;
10378 keys.push(owned_sql_value(RowParam::Cell(&key))?);
10379 }
10380 } else if deleted.insert(&op.row_id) {
10381 keys.extend(
10382 lookup
10383 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
10384 .map_err(|error| error.to_string())?
10385 .collect::<Result<Vec<_>, _>>()
10386 .map_err(|error| error.to_string())?,
10387 );
10388 }
10389 }
10390 for chunk in keys.chunks(400) {
10393 let holes = vec!["?"; chunk.len()].join(",");
10394 self.conn
10395 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
10396 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10397 .map_err(|error| error.to_string())?;
10398 }
10399 let updates = table
10400 .columns
10401 .iter()
10402 .map(|column| column.name.as_str())
10403 .chain(std::iter::once("_syncular_version"))
10404 .filter(|name| *name != table.primary_key)
10405 .map(|name| {
10406 let name = quote_ident(name);
10407 format!("{name}=excluded.{name}")
10408 })
10409 .collect::<Vec<_>>()
10410 .join(",");
10411 for chunk in keys.chunks(400) {
10412 let holes = vec!["?"; chunk.len()].join(",");
10413 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
10414 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10415 .map_err(|error| error.to_string())?;
10416 }
10417 Ok(())
10418 }
10419
10420 fn compile_local_data_purge(
10423 &self,
10424 input: &LocalDataPurgeInput,
10425 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
10426 let invalid = |message: String| format!("sync.invalid_request: {message}");
10427 if input.purge_id.is_empty()
10428 || input.purge_id.len() > 128
10429 || !is_local_operation_code_like(&input.purge_id)
10430 {
10431 return Err(invalid(
10432 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
10433 ));
10434 }
10435 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
10436 return Err(invalid(format!(
10437 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
10438 )));
10439 }
10440
10441 let mut deduplicated: BTreeMap<
10442 String,
10443 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
10444 > = BTreeMap::new();
10445 for target in &input.targets {
10446 let table = self.schema.table(&target.table).ok_or_else(|| {
10447 invalid(format!(
10448 "local purge names unknown table {:?}",
10449 target.table
10450 ))
10451 })?;
10452 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
10453 return Err(invalid(format!(
10454 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
10455 target.table
10456 )));
10457 }
10458 let mut selectors = Vec::with_capacity(target.selectors.len());
10459 let mut canonical_selectors = BTreeMap::new();
10460 for (column_name, raw_values) in &target.selectors {
10461 let Some((column_index, column)) = table
10462 .columns
10463 .iter()
10464 .enumerate()
10465 .find(|(_, column)| column.name == *column_name)
10466 else {
10467 return Err(invalid(format!(
10468 "local purge target {:?} names unknown column {:?}",
10469 target.table, column_name
10470 )));
10471 };
10472 let encrypted = table
10473 .encrypted_columns
10474 .iter()
10475 .any(|candidate| candidate.index == column_index);
10476 if column.ty != ColumnType::String || encrypted {
10477 return Err(invalid(format!(
10478 "local purge selector {:?}.{:?} must be a plaintext string column",
10479 target.table, column_name
10480 )));
10481 }
10482 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
10483 return Err(invalid(format!(
10484 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
10485 target.table, column_name
10486 )));
10487 }
10488 let mut values = raw_values.clone();
10489 values.sort();
10490 values.dedup();
10491 if values.iter().any(|value| {
10492 value.is_empty()
10493 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
10494 || !is_local_operation_code_like(value)
10495 }) {
10496 return Err(invalid(format!(
10497 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
10498 )));
10499 }
10500 selectors.push((column_name.clone(), values.clone()));
10501 canonical_selectors.insert(column_name.clone(), values);
10502 }
10503 let canonical = LocalDataPurgeTarget {
10504 table: target.table.clone(),
10505 selectors: canonical_selectors,
10506 };
10507 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10508 deduplicated.insert(
10509 key,
10510 (
10511 CompiledLocalDataPurgeTarget {
10512 table: target.table.clone(),
10513 selectors,
10514 },
10515 canonical,
10516 ),
10517 );
10518 }
10519 let targets = deduplicated
10520 .values()
10521 .map(|(compiled, _)| compiled.clone())
10522 .collect::<Vec<_>>();
10523 let canonical = deduplicated
10524 .values()
10525 .map(|(_, target)| target.clone())
10526 .collect::<Vec<_>>();
10527 let canonical_plan =
10528 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10529 Ok((targets, canonical_plan))
10530 }
10531
10532 fn local_purge_base_row_ids(
10533 &self,
10534 targets: &[CompiledLocalDataPurgeTarget],
10535 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
10536 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10537 for target in targets {
10538 let table = self
10539 .schema
10540 .table(&target.table)
10541 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
10542 let mut clauses = Vec::with_capacity(target.selectors.len());
10543 let mut params = Vec::new();
10544 for (column, values) in &target.selectors {
10545 clauses.push(format!(
10546 "{} IN ({})",
10547 quote_ident(column),
10548 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
10549 ));
10550 params.extend(values.iter().cloned().map(SqlValue::Text));
10551 }
10552 let sql = format!(
10553 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
10554 quote_ident(&table.primary_key),
10555 base_table(&target.table),
10556 clauses.join(" AND ")
10557 );
10558 let mut statement = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
10559 let rows = statement
10560 .query_map(rusqlite::params_from_iter(params), |row| {
10561 row.get::<_, String>(0)
10562 })
10563 .map_err(|error| error.to_string())?;
10564 let ids = by_table.entry(target.table.clone()).or_default();
10565 for row in rows {
10566 ids.insert(row.map_err(|error| error.to_string())?);
10567 }
10568 }
10569 Ok(by_table)
10570 }
10571
10572 fn local_purge_values_match(
10573 target: &CompiledLocalDataPurgeTarget,
10574 values: &Map<String, Value>,
10575 ) -> bool {
10576 target.selectors.iter().all(|(column, allowed)| {
10577 values
10578 .get(column)
10579 .and_then(Value::as_str)
10580 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
10581 })
10582 }
10583
10584 pub fn purge_local_data(
10588 &mut self,
10589 input: &LocalDataPurgeInput,
10590 ) -> Result<LocalDataPurgeResult, String> {
10591 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
10592 let meta_key = format!("localPurge:{}", input.purge_id);
10593 let applied_plan = self
10594 .conn
10595 .query_row(
10596 "SELECT value FROM _syncular_meta WHERE key = ?1",
10597 rusqlite::params![meta_key],
10598 |row| row.get::<_, String>(0),
10599 )
10600 .optional()
10601 .map_err(|error| error.to_string())?;
10602 if let Some(applied_plan) = applied_plan {
10603 if applied_plan != canonical_plan {
10604 return Err(format!(
10605 "sync.invalid_request: local purge id {:?} was already used with a different plan",
10606 input.purge_id
10607 ));
10608 }
10609 return Ok(LocalDataPurgeResult {
10610 already_applied: true,
10611 purged_rows: 0,
10612 dropped_commits: 0,
10613 });
10614 }
10615
10616 let _ = self.drop_previous_version();
10620
10621 let prior_outbox = self.outbox.clone();
10622 let prior_rejection_count = self.rejections.len();
10623 let prior_overlay_dirty = self.overlay_dirty.get();
10624 self.begin_observation("syncular_local_purge")?;
10625 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
10626 let row_ids = self.local_purge_base_row_ids(&targets)?;
10627 let doomed = self
10628 .outbox
10629 .iter()
10630 .filter(|commit| {
10631 commit.ops.iter().any(|operation| {
10632 let matching_targets = targets
10633 .iter()
10634 .filter(|target| target.table == operation.table)
10635 .collect::<Vec<_>>();
10636 if matching_targets.is_empty() {
10637 return false;
10638 }
10639 if row_ids
10640 .get(&operation.table)
10641 .is_some_and(|ids| ids.contains(&operation.row_id))
10642 {
10643 return true;
10644 }
10645 operation.values.as_ref().is_some_and(|values| {
10646 matching_targets
10647 .iter()
10648 .any(|target| Self::local_purge_values_match(target, values))
10649 })
10650 })
10651 })
10652 .cloned()
10653 .collect::<Vec<_>>();
10654 let doomed_ids = doomed
10655 .iter()
10656 .map(|commit| commit.client_commit_id.clone())
10657 .collect::<BTreeSet<_>>();
10658 let mut batch = ChangeAccumulator::default();
10659 let mut rejections = Vec::new();
10660 for commit in &doomed {
10661 for operation in &commit.ops {
10662 batch.table(&operation.table);
10663 }
10664 let results = commit
10665 .ops
10666 .iter()
10667 .enumerate()
10668 .map(|(op_index, operation)| {
10669 let rejection = RejectionRecord {
10670 client_commit_id: commit.client_commit_id.clone(),
10671 op_index: op_index as i32,
10672 code: "client.local_data_purged".to_owned(),
10673 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
10674 retryable: false,
10675 details: None,
10676 operation: Some(CommitOperation::from(operation)),
10677 };
10678 rejections.push(rejection.clone());
10679 CommitOperationOutcome::Error { rejection }
10680 })
10681 .collect::<Vec<_>>();
10682 self.persist_commit_outcome(
10683 &commit.client_commit_id,
10684 CommitOutcomeStatus::Rejected,
10685 &results,
10686 Some(&commit.ops),
10687 )?;
10688 self.delete_outbox_persisted(&commit.client_commit_id)?;
10689 }
10690 if !doomed.is_empty() {
10691 self.outbox
10692 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
10693 self.rejections.extend(rejections);
10694 self.prune_commit_outcomes()?;
10695 self.overlay_dirty.set(true);
10696 batch.status = true;
10697 batch.rejections = true;
10698 batch.outcomes = true;
10699 }
10700
10701 let mut purged_rows = 0usize;
10702 for (table, ids) in &row_ids {
10703 if ids.is_empty() {
10704 continue;
10705 }
10706 batch.table(table);
10707 for row_id in ids {
10708 self.delete_base_row(table, row_id)?;
10709 purged_rows += 1;
10710 }
10711 }
10712 self.rebuild_overlay_if_dirty();
10713 self.delete_unreferenced_cached_blobs();
10714 self.conn
10715 .execute(
10716 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10717 rusqlite::params![meta_key, canonical_plan],
10718 )
10719 .map_err(|error| error.to_string())?;
10720 Ok((
10721 batch,
10722 LocalDataPurgeResult {
10723 already_applied: false,
10724 purged_rows,
10725 dropped_commits: doomed.len(),
10726 },
10727 ))
10728 })();
10729 let (batch, result) = match applied {
10730 Ok(value) => value,
10731 Err(error) => {
10732 self.rollback_observation("syncular_local_purge");
10733 self.outbox = prior_outbox;
10734 self.rejections.truncate(prior_rejection_count);
10735 self.overlay_dirty.set(prior_overlay_dirty);
10736 return Err(error);
10737 }
10738 };
10739 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
10740 self.rollback_observation("syncular_local_purge");
10741 self.outbox = prior_outbox;
10742 self.rejections.truncate(prior_rejection_count);
10743 self.overlay_dirty.set(prior_overlay_dirty);
10744 return Err(error);
10745 }
10746 Ok(result)
10747 }
10748
10749 pub fn rebootstrap_local_data(
10755 &mut self,
10756 input: &LocalDataRebootstrapInput,
10757 ) -> Result<LocalDataRebootstrapResult, String> {
10758 if input.rebootstrap_id.is_empty()
10759 || input.rebootstrap_id.len() > 128
10760 || !is_local_operation_code_like(&input.rebootstrap_id)
10761 {
10762 return Err(
10763 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
10764 .to_owned(),
10765 );
10766 }
10767 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
10768 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
10769 let (retained_commits, reset_subscriptions) =
10770 decode_local_rebootstrap_receipt(&persisted)?;
10771 return Ok(LocalDataRebootstrapResult {
10772 already_applied: true,
10773 retained_commits,
10774 reset_subscriptions,
10775 });
10776 }
10777 if self.stopped || self.schema_floor.is_some() {
10778 return Err(
10779 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
10780 .to_owned(),
10781 );
10782 }
10783
10784 let retained_commits = self.outbox.len();
10785 let reset_subscriptions = self.subs.len();
10786 let prior_subs = self.subs.clone();
10787 let prior_upgrading = self.upgrading;
10788 let prior_stopped = self.stopped;
10789 let prior_schema_floor = self.schema_floor.clone();
10790 let prior_overlay_dirty = self.overlay_dirty.get();
10791 let prior_sync_needed = self.sync_needed;
10792 let prior_sync_intents = self.sync_intent_queue.clone();
10793 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
10794
10795 self.begin_observation("syncular_local_rebootstrap")?;
10796 let mut batch = ChangeAccumulator::default();
10797 let applied = (|| -> Result<(), String> {
10798 self.run_schema_reset_observed(&mut batch, false, false)?;
10799 self.conn
10800 .execute(
10801 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10802 rusqlite::params![meta_key, receipt],
10803 )
10804 .map_err(|error| error.to_string())?;
10805 self.sync_needed = true;
10806 self.sync_intent_queue.push_back(SyncIntent::Interactive);
10807 batch.status = true;
10808 Ok(())
10809 })();
10810 if let Err(error) = applied {
10811 self.rollback_observation("syncular_local_rebootstrap");
10812 self.subs = prior_subs;
10813 self.upgrading = prior_upgrading;
10814 self.stopped = prior_stopped;
10815 self.schema_floor = prior_schema_floor;
10816 self.overlay_dirty.set(prior_overlay_dirty);
10817 self.sync_needed = prior_sync_needed;
10818 self.sync_intent_queue = prior_sync_intents;
10819 return Err(error);
10820 }
10821 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
10822 self.rollback_observation("syncular_local_rebootstrap");
10823 self.subs = prior_subs;
10824 self.upgrading = prior_upgrading;
10825 self.stopped = prior_stopped;
10826 self.schema_floor = prior_schema_floor;
10827 self.overlay_dirty.set(prior_overlay_dirty);
10828 self.sync_needed = prior_sync_needed;
10829 self.sync_intent_queue = prior_sync_intents;
10830 return Err(error);
10831 }
10832
10833 Ok(LocalDataRebootstrapResult {
10834 already_applied: false,
10835 retained_commits,
10836 reset_subscriptions,
10837 })
10838 }
10839
10840 fn purge_scope_rows(
10845 &mut self,
10846 table_name: &str,
10847 effective: &[(String, Vec<String>)],
10848 ) -> Result<(), ()> {
10849 if effective.is_empty() {
10850 return Ok(());
10851 }
10852 let table = self.schema.table(table_name).ok_or(())?.clone();
10853 let mut clauses = Vec::new();
10854 let mut params: Vec<SqlValue> = Vec::new();
10855 for (variable, values) in effective {
10856 let column = table.scope_column(variable).ok_or(())?;
10857 let placeholders: Vec<String> = values
10858 .iter()
10859 .map(|v| {
10860 params.push(SqlValue::Text(v.clone()));
10861 "?".to_owned()
10862 })
10863 .collect();
10864 clauses.push(format!(
10865 "{} IN ({})",
10866 quote_ident(column),
10867 placeholders.join(", ")
10868 ));
10869 }
10870 let sql = format!(
10871 "DELETE FROM {} WHERE {}",
10872 base_table(table_name),
10873 clauses.join(" AND ")
10874 );
10875 self.overlay_dirty.set(true);
10876 self.conn
10877 .execute(&sql, rusqlite::params_from_iter(¶ms))
10878 .map_err(|_| ())?;
10879 self.conn
10880 .execute(
10881 &format!(
10882 "DELETE FROM {} WHERE {}",
10883 visible_table(table_name),
10884 clauses.join(" AND ")
10885 ),
10886 rusqlite::params_from_iter(¶ms),
10887 )
10888 .map_err(|_| ())?;
10889 Ok(())
10890 }
10891
10892 fn drop_doomed_outbox(
10895 &mut self,
10896 table_name: &str,
10897 effective: &[(String, Vec<String>)],
10898 ) -> Result<bool, String> {
10899 if effective.is_empty() {
10900 return Ok(false);
10901 }
10902 let Some(table) = self.schema.table(table_name).cloned() else {
10903 return Ok(false);
10904 };
10905 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
10906 for (variable, values) in effective {
10907 match table.scope_column(variable) {
10908 Some(column) => mappings.push((column, values)),
10909 None => return Ok(false), }
10911 }
10912 let doomed: Vec<OutboxCommit> = self
10913 .outbox
10914 .iter()
10915 .filter(|commit| {
10916 commit.ops.iter().any(|op| {
10917 op.upsert
10918 && op.table == table_name
10919 && op.values.as_ref().is_some_and(|values| {
10920 mappings
10921 .iter()
10922 .all(|(column, allowed)| match values.get(*column) {
10923 Some(Value::String(s)) => allowed.contains(s),
10924 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
10925 _ => false,
10926 })
10927 })
10928 })
10929 })
10930 .cloned()
10931 .collect();
10932 if doomed.is_empty() {
10933 return Ok(false);
10934 }
10935 let mut rejections = Vec::new();
10936 for commit in &doomed {
10937 let results = commit
10938 .ops
10939 .iter()
10940 .enumerate()
10941 .map(|(op_index, operation)| {
10942 let rejection = RejectionRecord {
10943 client_commit_id: commit.client_commit_id.clone(),
10944 op_index: op_index as i32,
10945 code: "sync.scope_revoked".to_owned(),
10946 message: "the commit was dropped because its effective scope was revoked"
10947 .to_owned(),
10948 retryable: false,
10949 details: None,
10950 operation: Some(CommitOperation::from(operation)),
10951 };
10952 rejections.push(rejection.clone());
10953 CommitOperationOutcome::Error { rejection }
10954 })
10955 .collect::<Vec<_>>();
10956 self.persist_commit_outcome(
10957 &commit.client_commit_id,
10958 CommitOutcomeStatus::Rejected,
10959 &results,
10960 Some(&commit.ops),
10961 )?;
10962 self.delete_outbox_persisted(&commit.client_commit_id)?;
10963 }
10964 self.prune_commit_outcomes()?;
10965 let doomed_ids = doomed
10966 .iter()
10967 .map(|commit| commit.client_commit_id.as_str())
10968 .collect::<BTreeSet<_>>();
10969 self.outbox
10970 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
10971 self.rejections.extend(rejections);
10972 self.overlay_dirty.set(true);
10973 Ok(true)
10974 }
10975
10976 pub fn upload_blob(
10983 &mut self,
10984 bytes: &[u8],
10985 media_type: Option<String>,
10986 name: Option<String>,
10987 ) -> Result<Value, String> {
10988 let blob_id = blob_id_for(bytes);
10989 let now = self.clock_now_ms();
10990 let tx = self.conn.transaction().map_err(|e| e.to_string())?;
10991 tx.execute(
10992 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
10993 ON CONFLICT(blob_id) DO NOTHING",
10994 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
10995 )
10996 .map_err(|e| e.to_string())?;
10997 tx.execute(
10998 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
10999 rusqlite::params![blob_id, media_type, now],
11000 )
11001 .map_err(|e| e.to_string())?;
11002 tx.commit().map_err(|e| e.to_string())?;
11003 self.enforce_blob_cache_cap();
11004 let mut obj = Map::new();
11005 obj.insert("blobId".to_owned(), Value::from(blob_id));
11006 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
11007 if let Some(mt) = media_type {
11008 obj.insert("mediaType".to_owned(), Value::from(mt));
11009 }
11010 if let Some(n) = name {
11011 obj.insert("name".to_owned(), Value::from(n));
11012 }
11013 Ok(Value::Object(obj))
11014 }
11015
11016 pub fn fetch_blob_bytes(
11020 &mut self,
11021 transport: &mut dyn Transport,
11022 blob_id_or_ref: &str,
11023 ) -> Result<FetchedBlob, (String, String)> {
11024 let simple = |m: String| ("client.failed".to_owned(), m);
11025 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
11026 blob_id_or_ref.to_owned()
11027 } else {
11028 let value: Value = serde_json::from_str(blob_id_or_ref)
11029 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
11030 value
11031 .get("blobId")
11032 .and_then(Value::as_str)
11033 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
11034 .to_owned()
11035 };
11036 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
11037 return Ok(cached);
11038 }
11039 #[cfg(feature = "bench-internals")]
11045 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
11046 let bytes = match transport
11047 .blob_download(&blob_id)
11048 .map_err(|e| (e.code, e.message))?
11049 {
11050 BlobDownload::Bytes(bytes) => bytes,
11051 BlobDownload::Url {
11052 url,
11053 url_expires_at_ms,
11054 } => {
11055 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
11057 return Err((
11058 "sync.segment_expired".to_owned(),
11059 format!(
11060 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
11061 ),
11062 ));
11063 }
11064 transport
11065 .fetch_blob_url(&url)
11066 .map_err(|e| (e.code, e.message))?
11067 }
11068 };
11069 #[cfg(feature = "bench-internals")]
11070 drop(download_phase);
11071 #[cfg(feature = "bench-internals")]
11072 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
11073 if blob_id_for(&bytes) != blob_id {
11075 return Err(simple(format!(
11076 "blob content address mismatch for {blob_id}"
11077 )));
11078 }
11079 #[cfg(feature = "bench-internals")]
11080 drop(validate_phase);
11081 #[cfg(feature = "bench-internals")]
11082 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
11083 let now = self.clock_now_ms();
11084 self.conn
11085 .execute(
11086 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
11087 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
11088 )
11089 .map_err(|e| simple(e.to_string()))?;
11090 self.conn
11091 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
11092 .map_err(|e| simple(e.to_string()))?;
11093 self.enforce_blob_cache_cap();
11094 #[cfg(feature = "bench-internals")]
11095 drop(insert_phase);
11096 self.get_cached_blob_bytes(&blob_id)
11097 .map_err(simple)?
11098 .ok_or_else(|| simple("blob cache write failed".to_owned()))
11099 }
11100
11101 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
11102 #[cfg(feature = "bench-internals")]
11103 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
11104 let mut stmt = self
11105 .conn
11106 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
11107 .map_err(|e| e.to_string())?;
11108 let mut rows = stmt
11109 .query(rusqlite::params![blob_id])
11110 .map_err(|e| e.to_string())?;
11111 if let Some(row) = rows.next().map_err(|e| e.to_string())? {
11112 let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
11113 let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
11114 let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
11115 return Ok(Some(FetchedBlob {
11116 blob_id: blob_id.to_owned(),
11117 byte_length,
11118 bytes,
11119 media_type,
11120 }));
11121 }
11122 Ok(None)
11123 }
11124
11125 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
11127 let pending: Vec<(String, Option<String>)> = {
11128 let mut stmt = self
11129 .conn
11130 .prepare(
11131 "SELECT blob_id, media_type FROM (
11132 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
11133 UNION ALL
11134 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
11135 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
11136 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
11137 ) ORDER BY created_at_ms, blob_id",
11138 )
11139 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11140 let rows = stmt
11141 .query_map([], |row| {
11142 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
11143 })
11144 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11145 rows.collect::<Result<_, _>>()
11146 .map_err(|error| match error {
11147 rusqlite::Error::InvalidColumnType(..)
11148 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
11149 "sync.local_corrupt",
11150 "Pending blob upload metadata is invalid",
11151 ),
11152 _ => TransportError::new("client.failed", error.to_string()),
11153 })?
11154 };
11155 for (blob_id, media_type) in pending {
11156 let stored: Option<(SqlValue, SqlValue)> = self
11157 .conn
11158 .query_row(
11159 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
11160 rusqlite::params![blob_id],
11161 |row| Ok((row.get(0)?, row.get(1)?)),
11162 )
11163 .optional()
11164 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11165 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
11166 return Err(TransportError::new(
11167 "sync.local_corrupt",
11168 "Pending blob upload body is missing or corrupt",
11169 ));
11170 };
11171 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
11172 return Err(TransportError::new(
11173 "sync.local_corrupt",
11174 "Pending blob upload body is missing or corrupt",
11175 ));
11176 }
11177 self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
11178 self.conn
11179 .execute(
11180 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
11181 rusqlite::params![blob_id],
11182 )
11183 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11184 }
11185 Ok(())
11186 }
11187
11188 fn upload_one(
11195 &self,
11196 transport: &mut dyn Transport,
11197 blob_id: &str,
11198 bytes: &[u8],
11199 media_type: Option<&str>,
11200 ) -> Result<(), TransportError> {
11201 match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
11202 BlobUploadGrant::Present => return Ok(()), BlobUploadGrant::Url {
11204 url,
11205 url_expires_at_ms,
11206 } => {
11207 let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
11208 if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
11209 return Ok(());
11210 }
11211 }
11213 BlobUploadGrant::None => {
11214 }
11216 }
11217 transport.blob_upload(blob_id, bytes, media_type)
11218 }
11219
11220 fn visible_blob_ids_sql(&self) -> String {
11221 let mut selects = Vec::new();
11222 for table in &self.schema.tables {
11223 for column in table
11224 .columns
11225 .iter()
11226 .filter(|column| column.ty == ColumnType::BlobRef)
11227 {
11228 let identifier = quote_ident(&column.name);
11229 selects.push(format!(
11230 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
11231 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
11232 AND json_type({identifier}, '$.blobId') = 'text'",
11233 quote_ident(&table.name),
11234 ));
11235 }
11236 }
11237 if selects.is_empty() {
11238 "SELECT NULL AS blob_id WHERE 0".to_owned()
11239 } else {
11240 selects.join(" UNION ")
11241 }
11242 }
11243
11244 fn enforce_blob_cache_cap(&self) {
11247 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
11248 return;
11249 };
11250 let mut total: i64 = self
11251 .conn
11252 .query_row(
11253 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
11254 [],
11255 |row| row.get(0),
11256 )
11257 .unwrap_or(0);
11258 if total <= max_bytes {
11259 return;
11260 }
11261 let query = format!(
11262 "SELECT blob_id, byte_length FROM _syncular_blobs
11263 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11264 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11265 AND blob_id NOT IN ({})
11266 ORDER BY created_at_ms ASC, blob_id ASC",
11267 self.visible_blob_ids_sql(),
11268 );
11269 let candidates: Vec<(String, i64)> = {
11270 let Ok(mut stmt) = self.conn.prepare(&query) else {
11271 return;
11272 };
11273 let Ok(rows) = stmt.query_map([], |row| {
11274 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
11275 }) else {
11276 return;
11277 };
11278 rows.filter_map(Result::ok).collect()
11279 };
11280 for (blob_id, byte_length) in candidates {
11281 if total <= max_bytes {
11282 break;
11283 }
11284 let _ = self.conn.execute(
11285 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
11286 rusqlite::params![blob_id],
11287 );
11288 total -= byte_length;
11289 }
11290 }
11291
11292 fn delete_unreferenced_cached_blobs(&mut self) {
11293 #[cfg(feature = "bench-internals")]
11294 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
11295 if !self.schema_has_blobs() {
11296 return;
11297 }
11298 self.rebuild_overlay_if_dirty();
11299 let _ = self.conn.execute(
11300 &format!(
11301 "DELETE FROM _syncular_blobs
11302 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11303 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11304 AND blob_id NOT IN ({})",
11305 self.visible_blob_ids_sql(),
11306 ),
11307 [],
11308 );
11309 }
11310
11311 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
11316 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
11317 return sql.clone();
11318 }
11319 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
11320 columns.push(quote_ident("_syncular_version"));
11321 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
11322 let primary_key = quote_ident(&table.primary_key);
11323 let updates = columns
11324 .iter()
11325 .filter(|column| **column != primary_key)
11326 .map(|column| format!("{column}=excluded.{column}"))
11327 .collect::<Vec<_>>()
11328 .join(", ");
11329 let sql = format!(
11330 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
11331 columns.join(", "),
11332 placeholders.join(", ")
11333 );
11334 self.insert_sql
11335 .borrow_mut()
11336 .insert(full_table.to_owned(), sql.clone());
11337 sql
11338 }
11339
11340 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
11341 self.overlay_dirty.set(true);
11342 self.write_row(&base_table(table_name), table_name, row, version)
11343 }
11344
11345 fn write_row(
11346 &self,
11347 full_table: &str,
11348 table_name: &str,
11349 row: &Row,
11350 version: i64,
11351 ) -> Result<(), String> {
11352 #[cfg(feature = "bench-internals")]
11353 let _phase = self.benchmark_phases.start(Phase::RowWrite);
11354 let table = self
11355 .schema
11356 .table(table_name)
11357 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11358 let sql = self.insert_row_sql(full_table, table);
11359 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11360 let params = row
11361 .iter()
11362 .map(RowParam::Cell)
11363 .chain(std::iter::once(RowParam::Version(version)));
11364 stmt.execute(rusqlite::params_from_iter(params))
11365 .map_err(|e| e.to_string())?;
11366 Ok(())
11367 }
11368
11369 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
11370 let table = self
11371 .schema
11372 .table(table_name)
11373 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11374 self.overlay_dirty.set(true);
11375 let sql = format!(
11376 "DELETE FROM {} WHERE {}",
11377 base_table(table_name),
11378 row_id_predicate(table)
11379 );
11380 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11381 stmt.execute(rusqlite::params![row_id])
11382 .map_err(|e| e.to_string())?;
11383 Ok(())
11384 }
11385
11386 fn rebuild_overlay_if_dirty(&mut self) {
11389 if self.overlay_dirty.get() {
11390 self.rebuild_overlay();
11391 }
11392 }
11393
11394 fn rebuild_overlay(&mut self) {
11398 #[cfg(feature = "bench-internals")]
11399 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
11400 #[cfg(test)]
11401 self.overlay_rebuild_count
11402 .set(self.overlay_rebuild_count.get() + 1);
11403 self.exec("SAVEPOINT syncular_overlay");
11404 for table in self.schema.tables.clone() {
11405 for index in &table.fts_indexes {
11406 let _ = self.drop_fts_triggers(index);
11407 }
11408 let visible = visible_table(&table.name);
11409 let base = base_table(&table.name);
11410 self.exec(&format!("DELETE FROM {visible}"));
11411 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
11412 }
11413 for commit in &self.outbox {
11414 self.apply_outbox_ops(&commit.ops);
11415 }
11416 for table in self.schema.tables.clone() {
11417 for index in &table.fts_indexes {
11418 let _ = self.rebuild_fts_projection(&table, index);
11419 let _ = self.create_fts_triggers(&table, index);
11420 }
11421 }
11422 self.exec("RELEASE syncular_overlay");
11423 self.overlay_dirty.set(false);
11424 }
11425
11426 fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
11427 #[cfg(feature = "bench-internals")]
11428 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
11429 for op in ops {
11430 #[cfg(feature = "bench-internals")]
11431 if let Some(phase) = &mut phase {
11432 phase.unit();
11433 }
11434 let Some(table) = self.schema.table(&op.table) else {
11435 continue;
11436 };
11437 if op.upsert {
11438 let Some(values) = op.values.as_ref() else {
11439 continue;
11440 };
11441 let full = table
11446 .columns
11447 .iter()
11448 .all(|column| values.contains_key(&column.name));
11449 let local = self.visible_row(table, &op.row_id);
11450 if local.is_none() && !full {
11451 continue;
11452 }
11453 let mut row: Row = Vec::with_capacity(table.columns.len());
11454 let mut ok = true;
11455 for (index, column) in table.columns.iter().enumerate() {
11456 let incoming = match values.get(&column.name) {
11457 Some(raw) => json_to_column_value(column, Some(raw)),
11458 None => Ok(local
11459 .as_ref()
11460 .and_then(|(values, _)| values.get(index).cloned())
11461 .flatten()),
11462 };
11463 match incoming {
11464 Ok(value) => row.push(value),
11465 Err(_) => {
11466 ok = false;
11467 break;
11468 }
11469 }
11470 }
11471 if ok {
11472 let version = local.as_ref().map_or(-1, |(_, version)| *version);
11473 let _ = self.write_row(&visible_table(&table.name), &table.name, &row, version);
11474 }
11475 } else {
11476 let sql = format!(
11477 "DELETE FROM {} WHERE {}",
11478 visible_table(&table.name),
11479 row_id_predicate(table)
11480 );
11481 let _ = self
11482 .conn
11483 .prepare_cached(&sql)
11484 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]));
11485 }
11486 }
11487 }
11488
11489 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
11492 let sql = format!(
11493 "SELECT * FROM {} WHERE {}",
11494 visible_table(&table.name),
11495 row_id_predicate(table)
11496 );
11497 let mut stmt = self.conn.prepare_cached(&sql).ok()?;
11498 let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
11499 let row = rows.next().ok()??;
11500 let mut values = Vec::with_capacity(table.columns.len());
11501 for (index, column) in table.columns.iter().enumerate() {
11502 let raw = row.get_ref(index).ok()?;
11503 let json = sql_ref_to_json(column, raw);
11504 values.push(json_to_column_value(column, Some(&json)).ok()?);
11505 }
11506 let version: i64 = row.get(table.columns.len()).ok()?;
11507 Some((values, version))
11508 }
11509
11510 fn exec(&self, sql: &str) {
11511 let _ = self.conn.execute_batch(sql);
11512 }
11513
11514 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
11517 if self.realtime_connected {
11518 return Ok(());
11519 }
11520 transport
11521 .realtime_connect_for_client(&self.client_id)
11522 .map_err(|e| format!("{}: {}", e.code, e.message))?;
11523 self.realtime_connected = true;
11524 Ok(())
11525 }
11526
11527 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
11528 if !self.realtime_connected {
11529 return;
11530 }
11531 let _ = transport.realtime_close();
11532 self.realtime_connected = false;
11533 self.presence.clear(); }
11535
11536 pub fn set_presence(
11542 &mut self,
11543 transport: &mut dyn Transport,
11544 scope_key: &str,
11545 doc: Option<&Value>,
11546 ) -> Result<(), String> {
11547 if !self.realtime_connected {
11548 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
11549 }
11550 let text = encode_presence_publish(scope_key, doc);
11551 transport
11552 .realtime_send(&text)
11553 .map_err(|e| format!("{}: {}", e.code, e.message))
11554 }
11555
11556 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
11558 self.presence
11559 .get(scope_key)
11560 .map(|peers| peers.values().cloned().collect())
11561 .unwrap_or_default()
11562 }
11563
11564 fn apply_presence(
11566 &mut self,
11567 scope_key: String,
11568 kind: Option<PresenceKind>,
11569 actor_id: Option<String>,
11570 client_id: Option<String>,
11571 doc: Option<Value>,
11572 error: Option<String>,
11573 ) {
11574 if error.is_some() {
11577 return;
11578 }
11579 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
11580 return;
11581 };
11582 let peer_key = format!("{actor_id} {client_id}");
11583 match kind {
11584 PresenceKind::Leave => {
11585 if let Some(peers) = self.presence.get_mut(&scope_key) {
11586 peers.remove(&peer_key);
11587 if peers.is_empty() {
11588 self.presence.remove(&scope_key);
11589 }
11590 }
11591 }
11592 _ => {
11593 let doc = match doc {
11594 Some(Value::Object(_)) => doc.unwrap(),
11595 _ => return,
11596 };
11597 self.presence.entry(scope_key).or_default().insert(
11598 peer_key,
11599 PresencePeer {
11600 actor_id,
11601 client_id,
11602 doc,
11603 },
11604 );
11605 }
11606 }
11607 }
11608
11609 pub fn on_realtime_text(&mut self, text: &str) {
11611 match parse_control(text) {
11612 Ok(ControlMessage::Hello { requires_sync, .. }) => {
11613 if requires_sync {
11614 self.set_sync_needed(true, true);
11616 }
11617 }
11618 Ok(ControlMessage::Presence {
11619 scope_key,
11620 kind,
11621 actor_id,
11622 client_id,
11623 doc,
11624 error,
11625 ..
11626 }) => {
11627 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
11628 }
11629 Ok(ControlMessage::Wake { .. }) => {
11630 self.set_sync_needed(true, true);
11632 }
11633 _ => {}
11634 }
11635 }
11636
11637 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
11640 if self.stopped {
11641 return;
11642 }
11643 #[cfg(feature = "bench-internals")]
11644 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
11645 let message = match decode_message(bytes) {
11646 Ok(m) if m.msg_kind == MsgKind::Response => m,
11647 _ => {
11648 self.set_sync_needed(true, true);
11649 return;
11650 }
11651 };
11652 #[cfg(feature = "bench-internals")]
11653 drop(decode_phase);
11654 let mut frames = message.frames.into_iter();
11655 let mut applied_cursor: Option<i64> = None;
11656 let mut any_covered = false;
11657 let mut dropped = false;
11658 while let Some(frame) = frames.next() {
11659 let Frame::SubStart {
11660 id,
11661 status,
11662 effective_scopes,
11663 ..
11664 } = frame
11665 else {
11666 continue;
11667 };
11668 let mut body = Vec::new();
11669 let mut next_cursor: Option<i64> = None;
11670 for inner in frames.by_ref() {
11671 match inner {
11672 Frame::SubEnd {
11673 next_cursor: nc, ..
11674 } => {
11675 next_cursor = Some(nc);
11676 break;
11677 }
11678 Frame::Unknown { .. } => {}
11679 other => body.push(other),
11680 }
11681 }
11682 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
11683 dropped = true;
11684 continue;
11685 };
11686 let sub = &self.subs[sub_index];
11687 if status != SubStatus::Active
11690 || sub.state != SubState::Active
11691 || sub.bootstrap_state.is_some()
11692 || !sub.synced_once
11693 {
11694 dropped = true;
11695 continue;
11696 }
11697 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
11698 any_covered = true;
11700 continue;
11701 }
11702 let previous_effective = self.subs[sub_index].effective.clone();
11703 let previous_cursor = self.subs[sub_index].cursor;
11704 self.subs[sub_index].effective = Some(effective_scopes);
11705 let mut failed = false;
11706 for inner in body {
11707 match inner {
11708 Frame::Commit {
11709 tables, changes, ..
11710 } => {
11711 if self.apply_commit_frame(&tables, &changes).is_err() {
11712 failed = true;
11713 break;
11714 }
11715 }
11716 _ => {
11717 failed = true;
11718 break;
11719 }
11720 }
11721 }
11722 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
11723 self.apply_sub_end(sub_index, cursor, None)
11724 .ok()
11725 .map(|()| cursor)
11726 });
11727 let Some(next_cursor) = committed else {
11728 self.subs[sub_index].effective = previous_effective;
11729 self.subs[sub_index].cursor = previous_cursor;
11730 dropped = true;
11731 continue;
11732 };
11733 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
11734 }
11735 if let Some(cursor) = applied_cursor {
11736 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11738 let _ = transport.realtime_send(&ack);
11739 } else if !any_covered || dropped {
11740 self.set_sync_needed(true, true);
11742 }
11743 }
11744
11745 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
11749 if !self.realtime_connected {
11750 return;
11751 }
11752 let floor = self
11753 .subs
11754 .iter()
11755 .filter(|s| {
11756 s.state == SubState::Active
11757 && s.bootstrap_state.is_none()
11758 && s.synced_once
11759 && s.cursor >= 0
11760 })
11761 .map(|s| s.cursor)
11762 .min();
11763 if let Some(cursor) = floor {
11764 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11765 let _ = transport.realtime_send(&ack);
11766 }
11767 }
11768}
11769
11770#[cfg(test)]
11771mod previous_version_wiring_tests {
11772 use super::*;
11773 use serde_json::{json, Value};
11774
11775 fn previous_version_schema(version: i32) -> Value {
11776 json!({
11777 "version": version,
11778 "tables": [{
11779 "name": "tasks",
11780 "primaryKey": "id",
11781 "columns": [
11782 { "name": "id", "type": "string", "nullable": false },
11783 { "name": "note", "type": "string", "nullable": true }
11784 ],
11785 "scopes": []
11786 }]
11787 })
11788 }
11789
11790 fn previous_version_limits() -> crate::api::ClientLimits {
11791 crate::api::ClientLimits {
11792 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
11793 enabled: true,
11794 ..Default::default()
11795 }),
11796 ..Default::default()
11797 }
11798 }
11799
11800 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
11802 previous_version_limits()
11803 .previous_version_context
11804 .expect("configured")
11805 }
11806
11807 fn recapture_previous_version(client: &SyncClient) {
11810 let replica_path = client
11811 .previous_version_replica_path()
11812 .expect("file-backed replica");
11813 let outcome = crate::previous_version::capture_previous_version_from_replica(
11814 &client.conn,
11815 &replica_path,
11816 2,
11817 2,
11818 &previous_version_config(),
11819 1,
11820 )
11821 .expect("capture");
11822 assert!(matches!(
11823 outcome,
11824 crate::previous_version::CaptureOutcome::Captured(_)
11825 ));
11826 }
11827
11828 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
11829 Subscription {
11830 id: "wiring-sub".to_owned(),
11831 table: "tasks".to_owned(),
11832 requested: Vec::new(),
11833 params: None,
11834 cursor,
11835 bootstrap_state: bootstrap_state.map(str::to_owned),
11836 state,
11837 reason_code: None,
11838 effective: None,
11839 synced_once: false,
11840 }
11841 }
11842
11843 #[test]
11844 fn previous_version_read_reasons_come_from_live_client_state() {
11845 use crate::previous_version::{
11846 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
11847 };
11848 let path = std::env::temp_dir()
11849 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
11850 .to_string_lossy()
11851 .into_owned();
11852 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
11853 {
11854 let mut setup = SyncClient::open_path(
11855 "wiring".to_owned(),
11856 &previous_version_schema(1),
11857 previous_version_limits(),
11858 &path,
11859 )
11860 .expect("v1 install");
11861 setup
11862 .mutate(vec![Mutation::Upsert {
11863 table: "tasks".to_owned(),
11864 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
11865 base_version: None,
11866 }])
11867 .expect("seed a row");
11868 }
11869 let mut client = SyncClient::open_path(
11870 "wiring".to_owned(),
11871 &previous_version_schema(2),
11872 previous_version_limits(),
11873 &path,
11874 )
11875 .expect("v2 bump captures");
11876 assert!(std::path::Path::new(&container).exists());
11877 let spec = PreviousVersionReadSpec {
11878 table: "tasks".to_owned(),
11879 ..Default::default()
11880 };
11881
11882 recapture_previous_version(&client);
11884 client.lease_state = Some(LeaseState {
11885 lease_id: None,
11886 expires_at_ms: None,
11887 error_code: Some("lease.expired".to_owned()),
11888 });
11889 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
11890 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
11891 assert!(!snapshot.available);
11892 assert!(!std::path::Path::new(&container).exists());
11893
11894 recapture_previous_version(&client);
11896 client.set_now_ms(1_000);
11897 client.lease_state = Some(LeaseState {
11898 lease_id: Some("lease".to_owned()),
11899 expires_at_ms: Some(1_000),
11900 error_code: None,
11901 });
11902 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
11903 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
11904 assert!(!std::path::Path::new(&container).exists());
11905 client.lease_state = None;
11906
11907 recapture_previous_version(&client);
11909 client.subs.push(subscription(SubState::Revoked, 0, None));
11910 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
11911 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
11912 assert!(!std::path::Path::new(&container).exists());
11913 client.subs.clear();
11914
11915 recapture_previous_version(&client);
11918 client
11919 .subs
11920 .push(subscription(SubState::Active, 0, Some("resume")));
11921 assert!(!client.previous_version_coverage_complete());
11922 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
11923 assert!(snapshot.available);
11924 assert_eq!(snapshot.previous_version, Some(2));
11925 assert_eq!(snapshot.rows.len(), 1);
11926
11927 recapture_previous_version(&client);
11928 client.subs[0].bootstrap_state = None;
11929 assert!(client.previous_version_coverage_complete());
11930 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
11931 assert_eq!(
11932 snapshot.reason,
11933 Some(PreviousVersionReason::CoverageComplete)
11934 );
11935 assert!(!std::path::Path::new(&container).exists());
11936 client.subs.clear();
11937
11938 assert!(!client.previous_version_coverage_complete());
11940
11941 drop(client);
11942 let _ = std::fs::remove_file(&path);
11943 }
11944
11945 #[test]
11952 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
11953 let path = std::env::temp_dir()
11954 .join(format!(
11955 "syncular-prev-lifetime-{}.db",
11956 uuid::Uuid::new_v4()
11957 ))
11958 .to_string_lossy()
11959 .into_owned();
11960 let container = format!(
11961 "{path}{}",
11962 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
11963 );
11964 let exists = || std::path::Path::new(&container).exists();
11965 {
11966 let mut setup = SyncClient::open_path(
11967 "lifetime".to_owned(),
11968 &previous_version_schema(1),
11969 previous_version_limits(),
11970 &path,
11971 )
11972 .expect("v1 install");
11973 setup
11974 .mutate(vec![Mutation::Upsert {
11975 table: "tasks".to_owned(),
11976 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
11977 base_version: None,
11978 }])
11979 .expect("seed a row");
11980 }
11981 let mut client = SyncClient::open_path(
11982 "lifetime".to_owned(),
11983 &previous_version_schema(2),
11984 previous_version_limits(),
11985 &path,
11986 )
11987 .expect("v2 bump captures");
11988 client.set_now_ms(1_000);
11991 assert!(exists());
11992
11993 client.lease_state = Some(LeaseState {
11995 lease_id: None,
11996 expires_at_ms: None,
11997 error_code: Some("lease.expired".to_owned()),
11998 });
11999 client.previous_version_lifetime_check();
12000 assert!(!exists());
12001 client.lease_state = None;
12002
12003 recapture_previous_version(&client);
12006 client.subs.push(subscription(SubState::Active, 0, None));
12007 client.previous_version_lifetime_check();
12008 assert!(!exists());
12009 client.subs.clear();
12010
12011 recapture_previous_version(&client);
12013 client.subs.push(subscription(SubState::Revoked, 0, None));
12014 client.previous_version_lifetime_check();
12015 assert!(!exists());
12016 client.subs.clear();
12017
12018 recapture_previous_version(&client);
12021 client
12022 .subs
12023 .push(subscription(SubState::Active, 0, Some("resume")));
12024 client.previous_version_lifetime_check();
12025 assert!(exists());
12026 client.subs.clear();
12027
12028 drop(client);
12029 let _ = std::fs::remove_file(&path);
12030 let _ = std::fs::remove_file(&container);
12031 let _ = std::fs::remove_file(format!("{container}-wal"));
12032 let _ = std::fs::remove_file(format!("{container}-shm"));
12033 }
12034}