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, QueryRow, QuerySnapshot, QueryValue,
37 RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RowState, SchemaFloor,
38 SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot, TableChange,
39 WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
40 CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
41};
42#[cfg(feature = "bench-internals")]
43use crate::bench::{Phase, Recorder};
44use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
45use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
46use crate::values::{
47 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
48 encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
49 normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
50};
51
52const DEFAULT_ACCEPT: u8 = 0b0111;
56const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
57const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
58const ACCEPT_SQLITE: u8 = 1 << 2;
59const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
60const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
61
62const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
64const LOCAL_REVISION_KEY: &str = "localRevision";
65const CLIENT_ID_KEY: &str = "clientId";
66const LEASE_STATE_KEY: &str = "leaseState";
67const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
68const LOG_EPOCH_KEY: &str = "logEpoch";
69const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
74const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
75const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
76const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
79const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
82
83#[derive(Debug, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase", deny_unknown_fields)]
85struct PersistedLocalDataRebootstrapReceipt {
86 version: u8,
87 retained_commits: u64,
88 reset_subscriptions: u64,
89}
90
91fn invalid_local_rebootstrap_receipt() -> String {
92 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
93}
94
95fn encode_local_rebootstrap_receipt(
96 retained_commits: usize,
97 reset_subscriptions: usize,
98) -> Result<String, String> {
99 let retained_commits =
100 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
101 let reset_subscriptions =
102 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
103 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
104 return Err(invalid_local_rebootstrap_receipt());
105 }
106 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
107 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
108 retained_commits,
109 reset_subscriptions,
110 })
111 .map_err(|_| invalid_local_rebootstrap_receipt())
112}
113
114fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
115 if value == "v1" {
117 return Ok((0, 0));
118 }
119 let receipt: PersistedLocalDataRebootstrapReceipt =
120 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
121 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
122 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
123 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
124 {
125 return Err(invalid_local_rebootstrap_receipt());
126 }
127 Ok((
128 usize::try_from(receipt.retained_commits)
129 .map_err(|_| invalid_local_rebootstrap_receipt())?,
130 usize::try_from(receipt.reset_subscriptions)
131 .map_err(|_| invalid_local_rebootstrap_receipt())?,
132 ))
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136enum SubState {
137 Active,
138 Revoked,
139 Failed,
140}
141
142#[cfg(test)]
143mod observation_tests {
144 use super::*;
145 use crate::native_transport::HostTransport;
146 use crate::values::encode_row_json;
147 use serde_json::json;
148
149 fn client() -> SyncClient {
150 SyncClient::new(
151 "retry-test".to_owned(),
152 &json!({
153 "version": 1,
154 "tables": [{
155 "name": "tasks",
156 "primaryKey": "id",
157 "columns": [
158 { "name": "id", "type": "string", "nullable": false },
159 { "name": "project_id", "type": "string", "nullable": false }
160 ],
161 "scopes": [{ "pattern": "project:{project_id}" }]
162 }]
163 }),
164 ClientLimits::default(),
165 )
166 .expect("test client")
167 }
168
169 #[cfg(feature = "e2ee")]
170 #[test]
171 fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
172 let schema = json!({
173 "version": 1,
174 "tables": [{
175 "name": "secrets",
176 "primaryKey": "id",
177 "columns": [
178 { "name": "id", "type": "string", "nullable": false },
179 { "name": "encryption_key_id", "type": "string", "nullable": true },
180 { "name": "note", "type": "bytes", "nullable": true,
181 "encrypted": true, "declaredType": "string" }
182 ],
183 "scopes": []
184 }]
185 });
186 let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
187 .expect("test client");
188 let mut config = crate::values::EncryptionConfig::default();
189 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
190 config
191 .key_id_columns
192 .insert("secrets".to_owned(), "encryption_key_id".to_owned());
193 client.set_encryption(config);
194 let commit_id = client
197 .patch(
198 "secrets",
199 "ghost",
200 Map::from_iter([("note".to_owned(), json!("x"))]),
201 None,
202 )
203 .expect("patch records without an author-time encrypt failure");
204 assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
205 assert!(client.drop_unencodable_outbox().unwrap());
207 assert!(client.pending_commit_ids().is_empty());
208 let rejections = client.rejections();
209 assert_eq!(rejections.len(), 1);
210 assert_eq!(rejections[0].code, "client.encrypt_failed");
211 assert_eq!(rejections[0].client_commit_id, commit_id);
212 assert_eq!(
213 client
214 .commit_outcome(&commit_id)
215 .unwrap()
216 .map(|outcome| outcome.status),
217 Some(CommitOutcomeStatus::Rejected)
218 );
219 }
220
221 #[test]
222 fn patch_with_a_scope_column_matches_the_stored_local_row() {
223 let mut client = client();
224 client.create_synced_tables().unwrap();
225 client
226 .conn
227 .execute(
228 "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
229 [],
230 )
231 .unwrap();
232
233 client
236 .patch(
237 "tasks",
238 "t1",
239 Map::from_iter([
240 ("project_id".to_owned(), json!("p1")),
241 ("id".to_owned(), json!("t1")),
242 ]),
243 None,
244 )
245 .expect("an equal scope column is a no-op");
246 let values = client.outbox[0].ops[0]
247 .values
248 .clone()
249 .expect("the patch records values");
250 assert_eq!(values.get("id"), Some(&json!("t1")));
251 assert!(!values.contains_key("project_id"));
252
253 let differing = client.patch(
255 "tasks",
256 "t1",
257 Map::from_iter([("project_id".to_owned(), json!("p2"))]),
258 None,
259 );
260 assert!(differing
261 .expect_err("a differing scope column is rejected")
262 .contains("patch cannot write scope column"));
263
264 let absent = client.patch(
267 "tasks",
268 "ghost",
269 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
270 None,
271 );
272 assert!(absent
273 .expect_err("an absent local row is rejected")
274 .contains("patch cannot write scope column"));
275 assert_eq!(client.pending_commit_ids().len(), 1);
276 }
277
278 #[test]
279 fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
280 let schema = json!({
281 "version": 1,
282 "tables": [
283 {
284 "name": "tenants",
285 "primaryKey": "tenant_id",
286 "columns": [
287 { "name": "tenant_id", "type": "string", "nullable": false },
288 { "name": "body", "type": "string", "nullable": false }
289 ],
290 "scopes": [{ "pattern": "tenant:{tenant_id}" }]
291 },
292 {
293 "name": "buckets",
294 "primaryKey": "id",
295 "columns": [
296 { "name": "id", "type": "string", "nullable": false },
297 { "name": "bucket", "type": "float", "nullable": false },
298 { "name": "body", "type": "string", "nullable": false }
299 ],
300 "scopes": [{ "pattern": "bucket:{bucket}" }]
301 }
302 ]
303 });
304 let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
305 .expect("test client");
306 client.create_synced_tables().unwrap();
307
308 client
312 .patch(
313 "tenants",
314 "t1",
315 Map::from_iter([
316 ("tenant_id".to_owned(), json!("t1")),
317 ("body".to_owned(), json!("created")),
318 ]),
319 None,
320 )
321 .expect("the primary-key scope column is proven equal by construction");
322
323 client
325 .conn
326 .execute(
327 "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
328 [],
329 )
330 .unwrap();
331 client
332 .patch(
333 "buckets",
334 "b1",
335 Map::from_iter([
336 ("bucket".to_owned(), json!(2)),
337 ("body".to_owned(), json!("coerced")),
338 ]),
339 None,
340 )
341 .expect("the stored REAL value coerces from a supplied integer");
342 let values = client.outbox[1].ops[0]
343 .values
344 .clone()
345 .expect("the patch records values");
346 assert!(!values.contains_key("bucket"));
347
348 let differing = client.patch(
350 "buckets",
351 "b1",
352 Map::from_iter([("bucket".to_owned(), json!(3))]),
353 None,
354 );
355 assert!(differing
356 .expect_err("a differing scope value is rejected")
357 .contains("patch cannot write scope column"));
358 }
359
360 #[cfg(feature = "e2ee")]
361 #[test]
362 fn stored_key_fallback_resolves_an_integer_primary_key() {
363 let schema = json!({
364 "version": 1,
365 "tables": [{
366 "name": "patients",
367 "primaryKey": "id",
368 "columns": [
369 { "name": "id", "type": "integer", "nullable": false },
370 { "name": "encryption_key_id", "type": "string", "nullable": true },
371 { "name": "note", "type": "bytes", "nullable": true,
372 "encrypted": true, "declaredType": "string" }
373 ],
374 "scopes": []
375 }]
376 });
377 let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
378 .expect("test client");
379 client.create_synced_tables().unwrap();
380 client
381 .conn
382 .execute(
383 "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
384 VALUES (5, 'k1', NULL, 1)",
385 [],
386 )
387 .unwrap();
388 let mut config = crate::values::EncryptionConfig::default();
389 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
390 config
391 .key_id_columns
392 .insert("patients".to_owned(), "encryption_key_id".to_owned());
393 client.set_encryption(config);
394 let table = client.schema.table("patients").unwrap();
395 let fallback = client
396 .stored_key_fallback(table, "5")
397 .expect("the stored integer key resolves the fallback");
398 assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
399 }
400
401 #[test]
402 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
403 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
404 {"name":"id","type":"string","nullable":false},
405 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
406 for state in ["absent", "cached", "pinned"] {
407 let path = std::env::temp_dir().join(format!(
408 "syncular-blob-stage-{}.sqlite",
409 uuid::Uuid::new_v4()
410 ));
411 let mut client = SyncClient::open_path(
412 "stage-test".into(),
413 &schema,
414 Default::default(),
415 path.to_str().unwrap(),
416 )
417 .unwrap();
418 let bytes = b"atomic stage";
419 if state != "absent" {
420 client
421 .upload_blob(bytes, Some("text/plain".into()), None)
422 .unwrap();
423 if state == "cached" {
424 client
425 .conn
426 .execute("DELETE FROM _syncular_blob_uploads", [])
427 .unwrap();
428 }
429 }
430 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
431 client
432 .conn
433 .execute_batch(
434 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
435 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
436 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
437 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
438 )
439 .unwrap();
440 let error = client
441 .upload_blob(bytes, Some("application/octet-stream".into()), None)
442 .unwrap_err();
443 assert!(error.contains("injected stage failure"), "{state}: {error}");
444 assert!(client.conn.is_autocommit());
445 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
446 drop(client);
447 let mut reopened = SyncClient::open_path(
448 "stage-test".into(),
449 &schema,
450 Default::default(),
451 path.to_str().unwrap(),
452 )
453 .unwrap();
454 let durable = reopened
455 .query("SELECT * FROM _syncular_blobs", &[])
456 .unwrap();
457 reopened
458 .conn
459 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
460 .unwrap();
461 let retried = reopened.upload_blob(bytes, None, None).unwrap();
462 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
463 assert_eq!(
464 reopened
465 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
466 .unwrap(),
467 vec![Map::from_iter([(
468 "blob_id".into(),
469 Value::from(blob_id_for(bytes)),
470 )])]
471 );
472 drop(reopened);
473 std::fs::remove_file(&path).unwrap();
474 assert_eq!(durable, bodies, "{state}: reopened body state");
475 assert_eq!(immediate, bodies, "{state}: immediate body state");
476 }
477 }
478
479 #[test]
480 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
481 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
482 {"name":"id","type":"string","nullable":false},
483 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
484 for fault in [
485 "missing",
486 "body-type",
487 "length",
488 "hash",
489 "pin-metadata",
490 "read",
491 "pin-delete",
492 ] {
493 let path = std::env::temp_dir().join(format!(
494 "syncular-blob-upload-{}.sqlite",
495 uuid::Uuid::new_v4()
496 ));
497 let mut client = SyncClient::open_path(
498 "upload-test".into(),
499 &schema,
500 Default::default(),
501 path.to_str().unwrap(),
502 )
503 .unwrap();
504 let bytes = b"durable upload";
505 let ref_value = client
506 .upload_blob(bytes, Some("text/plain".into()), None)
507 .unwrap();
508 let commit = client
509 .mutate(vec![Mutation::Upsert {
510 table: "attachments".into(),
511 values: Map::from_iter([
512 ("id".into(), Value::from("pending")),
513 (
514 "file".into(),
515 Value::from(format!(
516 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
517 blob_id_for(bytes),
518 bytes.len()
519 )),
520 ),
521 ]),
522 base_version: None,
523 }])
524 .unwrap();
525 client
526 .conn
527 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
528 .unwrap();
529 client.conn.execute_batch(match fault {
530 "missing" => "DELETE FROM _syncular_blobs",
531 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
532 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
533 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
534 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
535 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
536 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
537 }).unwrap();
538 let state = if fault == "read" {
539 Vec::new()
540 } else {
541 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
542 };
543 let mut transport = CountingRealtimeTransport::default();
544 let error = client.flush_blob_uploads(&mut transport).unwrap_err();
545 assert_eq!(
546 error.code,
547 if matches!(fault, "read" | "pin-delete") {
548 "client.failed"
549 } else {
550 "sync.local_corrupt"
551 },
552 "{fault}"
553 );
554 assert_eq!(
555 transport.blob_uploads.len(),
556 usize::from(fault == "pin-delete"),
557 "{fault}: corrupt bytes must never be uploaded"
558 );
559 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
560 if fault != "read" {
561 assert_eq!(
562 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
563 state
564 );
565 } else {
566 client
567 .conn
568 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
569 .unwrap();
570 }
571 drop(client);
572 let mut reopened = SyncClient::open_path(
573 "upload-test".into(),
574 &schema,
575 Default::default(),
576 path.to_str().unwrap(),
577 )
578 .unwrap();
579 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
580 if fault != "read" {
581 assert_eq!(
582 reopened
583 .query("SELECT * FROM _syncular_blobs", &[])
584 .unwrap(),
585 state
586 );
587 }
588 if fault == "pin-delete" {
589 reopened
590 .conn
591 .execute_batch("DROP TRIGGER fail_pin_delete")
592 .unwrap();
593 }
594 if fault == "pin-metadata" {
595 reopened
596 .conn
597 .execute(
598 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
599 [],
600 )
601 .unwrap();
602 }
603 reopened
604 .conn
605 .execute_batch(
606 "DELETE FROM _syncular_blobs;
607 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
608 )
609 .unwrap();
610 transport.blob_uploads.clear();
611 reopened.flush_blob_uploads(&mut transport).unwrap();
612 assert_eq!(
613 transport.blob_uploads,
614 [(blob_id_for(bytes), bytes.to_vec())]
615 );
616 assert_eq!(
617 reopened
618 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
619 .unwrap(),
620 Vec::<Map<String, Value>>::new()
621 );
622 assert_eq!(reopened.pending_commit_ids(), [commit]);
623 assert_eq!(
624 reopened
625 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
626 .unwrap()
627 .unwrap()
628 .bytes,
629 bytes
630 );
631 drop(reopened);
632 std::fs::remove_file(path).unwrap();
633 }
634 }
635
636 #[test]
637 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
638 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
639 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
640 "scopes":[{"pattern":"project:{project_id}"}]}]});
641 for realtime in [false, true] {
642 for fault in [
643 "none",
644 "row",
645 "revision",
646 "commit",
647 "cursor",
648 "error",
649 "missing-end",
650 ] {
651 if realtime && fault == "missing-end" {
652 continue;
653 }
654 let path = std::env::temp_dir()
655 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
656 let mut client = SyncClient::open_path(
657 "frame-test".into(),
658 &schema,
659 Default::default(),
660 path.to_str().unwrap(),
661 )
662 .unwrap();
663 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
664 client
665 .subscribe(
666 "tasks".into(),
667 "tasks".into(),
668 vec![("project_id".into(), vec!["p1".into()])],
669 None,
670 )
671 .unwrap();
672 client.subs[0].cursor = 0;
673 client.subs[0].synced_once = true;
674 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
675 client.persist_sub(&client.subs[0]).unwrap();
676 client.realtime_connected = true;
677 client.drain_change_batches();
678 let mut transport = CountingRealtimeTransport::default();
679 let (_, meta) = client.build_request(false);
680 let mut response = Message {
681 wire_version: WIRE_VERSION,
682 msg_kind: MsgKind::Response,
683 frames: vec![
684 Frame::RespHeader {
685 required_schema_version: None,
686 latest_schema_version: None,
687 log_epoch: Some("epoch-1".into()),
688 reset_required: Some(false),
689 },
690 Frame::SubStart {
691 id: "tasks".into(),
692 status: SubStatus::Active,
693 reason_code: "".into(),
694 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
695 bootstrap: false,
696 },
697 ],
698 };
699 for (index, id) in ["first", "second"].iter().enumerate() {
700 let values = Map::from_iter([
701 ("id".into(), Value::from(*id)),
702 ("project_id".into(), Value::from("p1")),
703 ]);
704 response.frames.push(Frame::Commit {
705 commit_seq: index as i64 + 1,
706 actor_id: "writer".into(),
707 created_at_ms: 1,
708 tables: vec!["tasks".into()],
709 changes: vec![ssp2::model::Change {
710 table_index: 0,
711 row_id: (*id).into(),
712 op: Op::Upsert,
713 row_version: Some(index as i64 + 1),
714 scopes: vec![("project_id".into(), "p1".into())],
715 row: Some(
716 encode_row_json(
717 client.schema.table("tasks").unwrap(),
718 id,
719 &values,
720 &client.encryption,
721 )
722 .unwrap(),
723 ),
724 }],
725 });
726 }
727 response.frames.push(Frame::SubEnd {
728 next_cursor: 2,
729 bootstrap_state: None,
730 });
731 let valid = response.clone();
732 match fault {
733 "row" => {
734 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
735 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
736 },
737 "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(),
738 "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(),
739 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
740 "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});},
741 "missing-end" => {response.frames.truncate(3);},
742 _ => {}
743 }
744 if realtime {
745 client.on_realtime_binary(&mut transport, &encode_message(&response));
746 } else {
747 assert_eq!(
748 matches!(
749 client.process_response(&mut transport, response, &meta),
750 SyncOutcome::Ok(_)
751 ),
752 fault == "none",
753 "{fault}"
754 );
755 }
756 assert!(
757 client.conn.is_autocommit(),
758 "no open transaction after {realtime}/{fault}"
759 );
760 let count = if matches!(fault, "none" | "cursor") {
761 2
762 } else {
763 1
764 };
765 let expected_revision = count + u64::from(realtime && fault != "none");
766 assert_eq!(
767 client.local_revision(),
768 expected_revision,
769 "{realtime}/{fault}"
770 );
771 let batches = client.drain_change_batches();
772 assert_eq!(
773 batches.len(),
774 expected_revision as usize,
775 "{realtime}/{fault}"
776 );
777 let row_batches: Vec<_> = batches
778 .iter()
779 .filter(|batch| !batch.tables.is_empty())
780 .collect();
781 assert_eq!(row_batches.len(), count as usize);
782 assert_eq!(
783 row_batches
784 .iter()
785 .map(|b| b.revision.clone())
786 .collect::<Vec<_>>(),
787 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
788 );
789 for table in ["tasks", "_syncular_base_tasks"] {
790 assert_eq!(
791 client
792 .conn
793 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
794 .get::<_, u64>(0))
795 .unwrap(),
796 count,
797 "{realtime}/{fault}/{table}"
798 );
799 }
800 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
801 assert_eq!(
802 transport
803 .messages
804 .iter()
805 .filter(|m| m.contains("\"ack\""))
806 .count(),
807 usize::from(fault == "none")
808 );
809 if matches!(fault, "revision" | "commit" | "cursor") {
810 client
811 .conn
812 .execute_batch("DROP TRIGGER fail_frame")
813 .unwrap();
814 }
815 drop(client);
816 let mut reopened = SyncClient::open_path(
817 "frame-test".into(),
818 &schema,
819 Default::default(),
820 path.to_str().unwrap(),
821 )
822 .unwrap();
823 assert_eq!(reopened.local_revision(), expected_revision);
824 assert_eq!(
825 reopened
826 .query("SELECT id FROM tasks ORDER BY id", &[])
827 .unwrap()
828 .len(),
829 count as usize
830 );
831 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
832 let (_, meta) = reopened.build_request(false);
833 assert!(matches!(
834 reopened.process_response(&mut transport, valid, &meta),
835 SyncOutcome::Ok(_)
836 ));
837 assert_eq!(
838 reopened
839 .query("SELECT id FROM tasks ORDER BY id", &[])
840 .unwrap()
841 .len(),
842 2
843 );
844 assert_eq!(reopened.subs[0].cursor, 2);
845 drop(reopened);
846 std::fs::remove_file(path).unwrap();
847 }
848 }
849 }
850
851 #[test]
852 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
853 for status in [SubStatus::Reset, SubStatus::Revoked] {
854 for fault in ["subscription", "revision"] {
855 let mut client = client();
856 client
857 .subscribe(
858 "tasks".into(),
859 "tasks".into(),
860 vec![("project_id".into(), vec!["p1".into()])],
861 None,
862 )
863 .unwrap();
864 client.subs[0].cursor = 41;
865 client.subs[0].synced_once = true;
866 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
867 client.persist_sub(&client.subs[0]).unwrap();
868 client
869 .mutate(vec![Mutation::Upsert {
870 table: "tasks".into(),
871 values: Map::from_iter([
872 ("id".into(), Value::from("pending")),
873 ("project_id".into(), Value::from("p1")),
874 ]),
875 base_version: None,
876 }])
877 .unwrap();
878 let ids = client.pending_commit_ids();
879 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
880 let revision = client.local_revision();
881 client.drain_change_batches();
882 let (_, meta) = client.build_request(false);
883 client.conn.execute_batch(if fault == "subscription" {
884 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
885 } else {
886 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
887 }).unwrap();
888 let mut transport = CountingRealtimeTransport::default();
889 let mut report = SyncReport::default();
890 if status == SubStatus::Reset {
892 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
893 }
894 let result = client.process_section(
895 &mut transport,
896 "tasks",
897 status,
898 "sync.scope_revoked",
899 vec![],
900 vec![],
901 Some((42, None)),
902 &meta,
903 &mut report,
904 );
905 assert!(result.is_err(), "{status:?}/{fault}");
906 assert!(client.conn.is_autocommit());
907 assert_eq!(client.subs[0].cursor, 41);
908 assert_eq!(client.subs[0].state, SubState::Active);
909 assert_eq!(client.pending_commit_ids(), ids);
910 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
911 assert!(client.rejections.is_empty());
912 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
913 assert_eq!(client.local_revision(), revision);
914 assert!(client.drain_change_batches().is_empty());
915 assert!(report.revoked.is_empty() && report.resets.is_empty());
916 }
917 }
918 }
919
920 #[test]
921 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
922 let mut client = client();
923 client
924 .subscribe(
925 "tasks".into(),
926 "tasks".into(),
927 vec![("project_id".into(), vec!["p1".into()])],
928 None,
929 )
930 .unwrap();
931 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
932 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
933 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
934 let row = |id: &str| ssp2::segment::SegmentRow {
935 server_version: 1,
936 values: vec![
937 Some(ColumnValue::String(id.into())),
938 Some(ColumnValue::String("p1".into())),
939 ],
940 };
941 let segment = RowsSegment {
942 table: "tasks".into(),
943 schema_version: 1,
944 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
945 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
946 };
947 assert!(client.apply_segment(0, &segment, true).is_err());
948 assert!(client.conn.is_autocommit());
949 assert_eq!(
950 client
951 .query("SELECT id FROM tasks ORDER BY id", &[])
952 .unwrap(),
953 vec![Map::from_iter([("id".into(), Value::from("first"))])]
954 );
955 assert_eq!(client.local_revision(), 1);
956 assert_eq!(client.drain_change_batches().len(), 1);
957 assert_eq!(client.subs[0].cursor, -1);
958 client
959 .conn
960 .execute_batch("DROP TRIGGER fail_block")
961 .unwrap();
962 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
963 assert_eq!(client.local_revision(), 3);
964 assert_eq!(client.drain_change_batches().len(), 2);
965 }
966
967 #[test]
968 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
969 assert_eq!(
970 client().diagnostics_storage().blob_cache_bytes_approx,
971 Some(0)
972 );
973 let client = SyncClient::new(
974 "diagnostic-storage".to_owned(),
975 &json!({
976 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
977 "columns": [{"name": "id", "type": "string", "nullable": false},
978 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
979 }),
980 ClientLimits {
981 blob_cache_max_bytes: Some(5),
982 ..Default::default()
983 },
984 )
985 .unwrap();
986 let empty = client.diagnostics_storage();
987 assert_eq!(empty.status, "healthy");
988 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
989 assert_eq!(empty.retained_outcome_entries, Some(0));
990 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
991 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
992 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
993 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
994 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
995 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
996 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
997 let written = client.diagnostics_storage();
998 assert_eq!(written.status, "pressure");
999 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1000 assert_eq!(written.retained_outcome_entries, Some(2));
1001 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1002 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1003 assert_eq!(
1004 written.pressure_reason_code.as_deref(),
1005 Some("client.blob_cache_over_limit")
1006 );
1007 client
1008 .conn
1009 .execute_batch(
1010 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1011 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1012 )
1013 .unwrap();
1014 let staged = client.diagnostics_storage();
1015 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1016 assert_eq!(staged.retained_outcome_entries, Some(0));
1017 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1018 assert_eq!(staged.status, "healthy");
1019 client.conn.execute_batch("ROLLBACK").unwrap();
1020 assert_eq!(client.diagnostics_storage(), written);
1021 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1022 let unreadable = client.diagnostics_storage();
1023 assert_eq!(unreadable.status, "unreadable");
1024 assert!(unreadable.database_bytes_approx.is_none());
1025 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1026 assert!(unreadable.retained_outcome_entries.is_none());
1027 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1028 assert!(unreadable.blob_cache_bytes_approx.is_none());
1029 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1030 let restored = client.diagnostics_storage();
1031 assert_eq!(restored.status, written.status);
1032 assert_eq!(
1033 restored.retained_outcome_bytes_approx,
1034 written.retained_outcome_bytes_approx
1035 );
1036 client.conn.set_prepared_statement_cache_capacity(0);
1037 assert_eq!(client.diagnostics_storage(), restored);
1038 }
1039
1040 #[test]
1041 fn background_retry_deadlines_back_off_and_reset() {
1042 let mut client = client();
1043 client.schedule_background_retry();
1044 assert!(matches!(
1045 client.drain_sync_intents().as_slice(),
1046 [SyncIntent::Background { delay_ms: 250 }]
1047 ));
1048 client.schedule_background_retry();
1049 assert!(matches!(
1050 client.drain_sync_intents().as_slice(),
1051 [SyncIntent::Background { delay_ms: 500 }]
1052 ));
1053 client.reset_background_retry();
1054 client.schedule_background_retry();
1055 assert!(matches!(
1056 client.drain_sync_intents().as_slice(),
1057 [SyncIntent::Background { delay_ms: 250 }]
1058 ));
1059 }
1060
1061 #[test]
1062 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1063 let mut client = client();
1064 client
1065 .subscribe(
1066 "epoch-tasks".to_owned(),
1067 "tasks".to_owned(),
1068 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1069 None,
1070 )
1071 .expect("subscribe");
1072 client.drain_sync_intents();
1073
1074 client
1075 .run_log_epoch_reset("epoch-2")
1076 .expect("reset partition log epoch");
1077
1078 assert!(matches!(
1079 client.drain_sync_intents().as_slice(),
1080 [SyncIntent::Interactive]
1081 ));
1082 }
1083
1084 #[derive(Default)]
1085 struct CountingRealtimeTransport {
1086 connects: usize,
1087 closes: usize,
1088 messages: Vec<String>,
1089 blob_uploads: Vec<(String, Vec<u8>)>,
1090 blob_download: Option<Vec<u8>>,
1091 }
1092
1093 impl Transport for CountingRealtimeTransport {
1094 fn blob_upload(
1095 &mut self,
1096 blob_id: &str,
1097 bytes: &[u8],
1098 _media_type: Option<&str>,
1099 ) -> Result<(), TransportError> {
1100 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1101 Ok(())
1102 }
1103
1104 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1105 self.blob_download
1106 .clone()
1107 .map(BlobDownload::Bytes)
1108 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1109 }
1110
1111 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1112 Err(TransportError::new("sync.transport_failed", "offline"))
1113 }
1114
1115 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1116 Err(TransportError::new("sync.transport_failed", "offline"))
1117 }
1118
1119 fn download_segment(
1120 &mut self,
1121 _request: &SegmentRequest,
1122 _on_progress: &mut dyn FnMut(u64),
1123 ) -> Result<Vec<u8>, TransportError> {
1124 Err(TransportError::new("sync.transport_failed", "offline"))
1125 }
1126
1127 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1128 self.connects += 1;
1129 Ok(())
1130 }
1131
1132 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1133 self.messages.push(text.to_owned());
1134 Ok(())
1135 }
1136
1137 fn realtime_close(&mut self) -> Result<(), TransportError> {
1138 self.closes += 1;
1139 Ok(())
1140 }
1141 }
1142
1143 #[test]
1144 fn blob_results_own_downloaded_bytes() {
1145 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1146 {"name":"id","type":"string","nullable":false},
1147 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1148 let bytes = b"owned typed result".to_vec();
1149 let blob_id = blob_id_for(&bytes);
1150 let mut transport = CountingRealtimeTransport {
1151 blob_download: Some(bytes.clone()),
1152 ..Default::default()
1153 };
1154 let result = {
1155 let mut client =
1156 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1157 .unwrap();
1158 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1159 assert_eq!(result.blob_id, blob_id);
1160 assert_eq!(result.byte_length, bytes.len() as i64);
1161 assert_eq!(result.bytes, bytes);
1162 assert_eq!(result.media_type, None);
1163 assert_eq!(
1164 client
1165 .conn
1166 .query_row(
1167 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1168 rusqlite::params![blob_id],
1169 |row| row.get::<_, i64>(0),
1170 )
1171 .unwrap(),
1172 0
1173 );
1174 result
1175 };
1176 assert_eq!(result.bytes, bytes);
1177
1178 let mut client =
1179 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1180 assert_eq!(
1181 client
1182 .fetch_blob_bytes(&mut transport, "not json")
1183 .unwrap_err(),
1184 (
1185 "client.failed".to_owned(),
1186 "blob ref is not JSON".to_owned()
1187 )
1188 );
1189 }
1190
1191 #[test]
1192 fn visible_blob_references_survive_cache_trimming() {
1193 let schema = json!({"version":1,"tables":[
1194 {"name":"attachments","primaryKey":"id","columns":[
1195 {"name":"id","type":"string","nullable":false},
1196 {"name":"body","type":"blob_ref","nullable":true},
1197 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1198 {"name":"avatars","primaryKey":"id","columns":[
1199 {"name":"id","type":"string","nullable":false},
1200 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1201 ]});
1202 let bytes = b"referenced above cap".to_vec();
1203 let blob_id = blob_id_for(&bytes);
1204 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1205 let mut client = SyncClient::new(
1206 "visible-blob-refs".to_owned(),
1207 &schema,
1208 ClientLimits {
1209 blob_cache_max_bytes: Some(1),
1210 ..Default::default()
1211 },
1212 )
1213 .unwrap();
1214 client.conn.execute(
1215 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1216 rusqlite::params![reference, reference, reference, reference],
1217 ).unwrap();
1218 client
1219 .conn
1220 .execute(
1221 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1222 rusqlite::params![reference],
1223 )
1224 .unwrap();
1225 client.conn.execute(
1226 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1227 [],
1228 ).unwrap();
1229 let mut transport = CountingRealtimeTransport {
1230 blob_download: Some(bytes.clone()),
1231 ..Default::default()
1232 };
1233
1234 assert_eq!(
1235 client
1236 .fetch_blob_bytes(&mut transport, &blob_id)
1237 .unwrap()
1238 .bytes,
1239 bytes
1240 );
1241 let rows = client
1242 .conn
1243 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1244 .unwrap()
1245 .query_map([], |row| row.get::<_, String>(0))
1246 .unwrap()
1247 .collect::<Result<Vec<_>, _>>()
1248 .unwrap();
1249 assert_eq!(rows, vec![blob_id]);
1250 }
1251
1252 #[test]
1253 fn realtime_connection_ownership_is_idempotent() {
1254 let mut client = client();
1255 let mut transport = CountingRealtimeTransport {
1256 connects: 0,
1257 closes: 0,
1258 ..Default::default()
1259 };
1260 client
1261 .connect_realtime(&mut transport)
1262 .expect("first connect");
1263 client
1264 .connect_realtime(&mut transport)
1265 .expect("idempotent connect");
1266 assert_eq!(transport.connects, 1);
1267 client.disconnect_realtime(&mut transport);
1268 client.disconnect_realtime(&mut transport);
1269 assert_eq!(transport.closes, 1);
1270 client
1271 .connect_realtime(&mut transport)
1272 .expect("deliberate reconnect");
1273 assert_eq!(transport.connects, 2);
1274 }
1275
1276 #[test]
1277 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1278 let mut client = client();
1279 client
1280 .subscribe(
1281 "repair-tasks".to_owned(),
1282 "tasks".to_owned(),
1283 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1284 None,
1285 )
1286 .expect("subscribe");
1287 {
1288 let sub = client
1289 .subs
1290 .iter_mut()
1291 .find(|sub| sub.id == "repair-tasks")
1292 .expect("subscription");
1293 sub.cursor = 42;
1294 sub.synced_once = true;
1295 let persisted = sub.clone();
1296 client.persist_sub(&persisted).unwrap();
1297 }
1298 for table in ["_syncular_base_tasks", "tasks"] {
1299 client
1300 .conn
1301 .execute(
1302 &format!(
1303 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1304 ),
1305 rusqlite::params!["server-row", "p1"],
1306 )
1307 .expect("seed server row");
1308 }
1309 let pending = client
1310 .mutate(vec![Mutation::Upsert {
1311 table: "tasks".to_owned(),
1312 values: Map::from_iter([
1313 ("id".to_owned(), Value::from("offline-row")),
1314 ("project_id".to_owned(), Value::from("p1")),
1315 ]),
1316 base_version: None,
1317 }])
1318 .expect("queue offline work");
1319 client.drain_change_batches();
1320 client.drain_sync_intents();
1321
1322 assert_eq!(
1323 client
1324 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1325 rebootstrap_id: "support-case-001".to_owned(),
1326 })
1327 .expect("rebootstrap"),
1328 LocalDataRebootstrapResult {
1329 already_applied: false,
1330 retained_commits: 1,
1331 reset_subscriptions: 1,
1332 }
1333 );
1334 let visible_ids = client
1335 .conn
1336 .prepare("SELECT id FROM tasks ORDER BY id")
1337 .expect("prepare visible ids")
1338 .query_map([], |row| row.get::<_, String>(0))
1339 .expect("query visible ids")
1340 .collect::<Result<Vec<_>, _>>()
1341 .expect("collect visible ids");
1342 assert_eq!(visible_ids, vec!["offline-row"]);
1343 assert_eq!(client.pending_commit_ids(), vec![pending]);
1344 assert_eq!(
1345 client
1346 .subscription_state("repair-tasks")
1347 .expect("subscription")
1348 .cursor,
1349 -1
1350 );
1351 assert!(client.upgrading());
1352 assert!(client.sync_needed());
1353 assert!(matches!(
1354 client.drain_sync_intents().as_slice(),
1355 [SyncIntent::Interactive]
1356 ));
1357 assert_eq!(client.drain_change_batches().len(), 1);
1358
1359 assert_eq!(
1360 client
1361 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1362 rebootstrap_id: "support-case-001".to_owned(),
1363 })
1364 .expect("idempotent retry"),
1365 LocalDataRebootstrapResult {
1366 already_applied: true,
1367 retained_commits: 1,
1368 reset_subscriptions: 1,
1369 }
1370 );
1371 assert!(client.drain_change_batches().is_empty());
1372 }
1373
1374 #[test]
1375 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1376 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1377 assert_eq!(
1378 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1379 (3, 4)
1380 );
1381 assert_eq!(
1382 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1383 (0, 0)
1384 );
1385 for malformed in [
1386 "",
1387 "{}",
1388 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1389 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1390 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1391 ] {
1392 assert_eq!(
1393 decode_local_rebootstrap_receipt(malformed)
1394 .expect_err("malformed receipt must fail"),
1395 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1396 );
1397 }
1398 }
1399
1400 #[test]
1401 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1402 let path = std::env::temp_dir().join(format!(
1403 "syncular-rebootstrap-receipt-{}.db",
1404 uuid::Uuid::new_v4()
1405 ));
1406 let schema = json!({
1407 "version": 1,
1408 "tables": [{
1409 "name": "tasks",
1410 "primaryKey": "id",
1411 "columns": [
1412 { "name": "id", "type": "string", "nullable": false },
1413 { "name": "project_id", "type": "string", "nullable": false }
1414 ],
1415 "scopes": [{ "pattern": "project:{project_id}" }]
1416 }]
1417 });
1418 let path_string = path.to_str().expect("UTF-8 temp path");
1419
1420 {
1421 let mut first = SyncClient::open_path(
1422 "repair-restart-client".to_owned(),
1423 &schema,
1424 ClientLimits::default(),
1425 path_string,
1426 )
1427 .expect("first open");
1428 first
1429 .subscribe(
1430 "repair-tasks".to_owned(),
1431 "tasks".to_owned(),
1432 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1433 None,
1434 )
1435 .expect("subscribe");
1436 first
1437 .mutate(vec![Mutation::Upsert {
1438 table: "tasks".to_owned(),
1439 values: Map::from_iter([
1440 ("id".to_owned(), Value::from("offline-row")),
1441 ("project_id".to_owned(), Value::from("p1")),
1442 ]),
1443 base_version: None,
1444 }])
1445 .expect("queue offline work");
1446 assert_eq!(
1447 first
1448 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1449 rebootstrap_id: "restart-receipt".to_owned(),
1450 })
1451 .expect("first rebootstrap"),
1452 LocalDataRebootstrapResult {
1453 already_applied: false,
1454 retained_commits: 1,
1455 reset_subscriptions: 1,
1456 }
1457 );
1458 }
1459
1460 let mut reopened = SyncClient::open_path(
1461 "repair-restart-client".to_owned(),
1462 &schema,
1463 ClientLimits::default(),
1464 path_string,
1465 )
1466 .expect("reopen");
1467 reopened.drain_change_batches();
1468 reopened.drain_sync_intents();
1469 assert_eq!(
1470 reopened
1471 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1472 rebootstrap_id: "restart-receipt".to_owned(),
1473 })
1474 .expect("receipt replay"),
1475 LocalDataRebootstrapResult {
1476 already_applied: true,
1477 retained_commits: 1,
1478 reset_subscriptions: 1,
1479 }
1480 );
1481 assert!(reopened.drain_change_batches().is_empty());
1482 assert!(reopened.drain_sync_intents().is_empty());
1483 drop(reopened);
1484 std::fs::remove_file(path).expect("remove temp database");
1485 }
1486
1487 #[test]
1488 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1489 let mut malformed = client();
1490 malformed
1491 .subscribe(
1492 "repair-tasks".to_owned(),
1493 "tasks".to_owned(),
1494 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1495 None,
1496 )
1497 .expect("subscribe");
1498 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1499 malformed.drain_change_batches();
1500 malformed.drain_sync_intents();
1501 let malformed_error = malformed
1502 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1503 rebootstrap_id: "malformed".to_owned(),
1504 })
1505 .expect_err("malformed receipt must fail closed");
1506 assert_eq!(
1507 malformed_error,
1508 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1509 );
1510 assert!(!malformed.upgrading());
1511 assert_eq!(
1512 malformed
1513 .subscription_state("repair-tasks")
1514 .expect("unchanged subscription")
1515 .cursor,
1516 -1
1517 );
1518 assert!(malformed.drain_change_batches().is_empty());
1519 assert!(malformed.drain_sync_intents().is_empty());
1520
1521 let mut unreadable = client();
1522 unreadable
1523 .conn
1524 .execute(
1525 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1526 rusqlite::params!["server-row", "p1"],
1527 )
1528 .expect("seed visible row");
1529 unreadable
1530 .conn
1531 .execute("DROP TABLE _syncular_meta", [])
1532 .expect("break marker storage");
1533 let unreadable_error = unreadable
1534 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1535 rebootstrap_id: "unreadable".to_owned(),
1536 })
1537 .expect_err("unreadable marker storage must fail closed");
1538 assert_eq!(
1539 unreadable_error,
1540 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1541 );
1542 let visible_rows = unreadable
1543 .conn
1544 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1545 .expect("visible projection remains");
1546 assert_eq!(visible_rows, 1);
1547 assert!(!unreadable.upgrading());
1548 assert!(unreadable.drain_change_batches().is_empty());
1549 assert!(unreadable.drain_sync_intents().is_empty());
1550 }
1551
1552 #[test]
1553 fn local_rebootstrap_cannot_bypass_schema_floor() {
1554 let mut client = client();
1555 client.set_schema_floor(Some(SchemaFloor {
1556 required_schema_version: Some(2),
1557 latest_schema_version: Some(2),
1558 }));
1559 let error = client
1560 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1561 rebootstrap_id: "blocked-floor".to_owned(),
1562 })
1563 .expect_err("schema floor must block repair");
1564 assert!(error.contains("cannot bypass an active schema-floor stop"));
1565 }
1566
1567 #[test]
1568 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1569 let mut client = client();
1570 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1571 const COMMIT_COUNT: usize = 32;
1572
1573 for index in 0..COMMIT_COUNT {
1574 client
1575 .mutate(vec![Mutation::Upsert {
1576 table: "tasks".to_owned(),
1577 values: Map::from_iter([
1578 ("id".to_owned(), Value::from(format!("task-{index}"))),
1579 ("project_id".to_owned(), Value::from("project-1")),
1580 ]),
1581 base_version: None,
1582 }])
1583 .expect("queue commit");
1584 }
1585
1586 let (_, request_meta) = client.build_request(false);
1587 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1588 let mut frames = vec![Frame::RespHeader {
1589 required_schema_version: None,
1590 latest_schema_version: None,
1591 log_epoch: Some("epoch-1".to_owned()),
1592 reset_required: Some(false),
1593 }];
1594 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1595 |(index, client_commit_id)| Frame::PushResult {
1596 client_commit_id: client_commit_id.clone(),
1597 status: PushStatus::Applied,
1598 commit_seq: Some(index as i64 + 1),
1599 results: vec![OpResult::Applied { op_index: 0 }],
1600 },
1601 ));
1602 let response = Message {
1603 wire_version: WIRE_VERSION,
1604 msg_kind: MsgKind::Response,
1605 frames,
1606 };
1607 let mut transport =
1608 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1609
1610 client.overlay_rebuild_count.set(0);
1611 client.outcome_prune_count.set(0);
1612 let outcome = client.process_response(&mut transport, response, &request_meta);
1613 assert!(matches!(outcome, SyncOutcome::Ok(_)));
1614 assert!(client.pending_commit_ids().is_empty());
1615 assert_eq!(
1616 client.overlay_rebuild_count.get(),
1617 1,
1618 "one response must reconcile its acknowledged commits with one overlay rebuild"
1619 );
1620 assert_eq!(
1621 client.outcome_prune_count.get(),
1622 1,
1623 "one response must enforce outcome retention once"
1624 );
1625 }
1626
1627 #[test]
1628 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1629 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1630 "columns": [
1631 {"name":"id", "type":"string", "nullable":false},
1632 {"name":"project_id", "type":"string", "nullable":false},
1633 {"name":"title", "type":"string", "nullable":false}
1634 ], "scopes": [{"pattern":"project:{project_id}"}],
1635 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1636 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1637 }] });
1638 let mut incremental =
1639 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1640 let mut reference =
1641 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1642 let tables = vec!["tasks".to_owned()];
1643 for index in 0..32 {
1644 let id = format!("task-{}", index % 8);
1645 let values = Map::from_iter([
1646 ("id".to_owned(), Value::from(id.clone())),
1647 (
1648 "project_id".to_owned(),
1649 Value::from(format!("p{}", index % 2)),
1650 ),
1651 ("title".to_owned(), Value::from(format!("needle{index}"))),
1652 ]);
1653 let payload = encode_row_json(
1654 incremental.schema.table("tasks").unwrap(),
1655 &id,
1656 &values,
1657 &incremental.encryption,
1658 )
1659 .unwrap();
1660 let change = ssp2::model::Change {
1661 table_index: 0,
1662 row_id: id,
1663 op: if index % 5 == 0 {
1664 Op::Delete
1665 } else {
1666 Op::Upsert
1667 },
1668 row_version: Some(index + 1),
1669 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1670 row: Some(payload),
1671 };
1672 reference.overlay_dirty.set(true);
1673 for instance in [&mut incremental, &mut reference] {
1674 instance.begin_observation("mirror_test").unwrap();
1675 let mut batch = ChangeAccumulator::default();
1676 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1677 instance
1678 .apply_commit_changes(&tables, std::slice::from_ref(&change))
1679 .unwrap();
1680 instance.rebuild_overlay_if_dirty();
1681 instance.finish_observation("mirror_test", batch).unwrap();
1682 }
1683 for sql in [
1684 "SELECT * FROM tasks ORDER BY id",
1685 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1686 ] {
1687 assert_eq!(
1688 incremental.query(sql, &[]).unwrap(),
1689 reference.query(sql, &[]).unwrap()
1690 );
1691 }
1692 assert_eq!(incremental.local_revision(), reference.local_revision());
1693 assert_eq!(
1694 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1695 serde_json::to_value(reference.drain_change_batches()).unwrap()
1696 );
1697 }
1698 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1699 assert_eq!(reference.overlay_rebuild_count.get(), 32);
1700 }
1701
1702 #[test]
1703 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1704 let cases = [
1705 (
1706 "string",
1707 vec![
1708 json!("1"),
1709 json!("01"),
1710 json!(""),
1711 json!("nul\u{0}id"),
1712 json!("é"),
1713 ],
1714 ),
1715 (
1716 "integer",
1717 vec![
1718 json!(i64::MIN),
1719 json!(-1),
1720 json!(0),
1721 json!(1),
1722 json!(i64::MAX),
1723 ],
1724 ),
1725 ("boolean", vec![json!(false), json!(true)]),
1726 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
1727 ];
1728 for (kind, ids) in cases {
1729 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1730 "columns":[{"name":"id","type":kind,"nullable":false},
1731 {"name":"project_id","type":"string","nullable":false}],
1732 "scopes":[{"pattern":"project:{project_id}"}]}]});
1733 let mut client =
1734 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
1735 for (index, id) in ids.iter().enumerate() {
1736 let table = client.schema.table("tasks").unwrap();
1737 client
1738 .write_base_row(
1739 "tasks",
1740 &vec![
1741 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
1742 Some(ColumnValue::String(format!("p{index}"))),
1743 ],
1744 1,
1745 )
1746 .unwrap();
1747 }
1748 client.rebuild_overlay_if_dirty();
1749 for base in [true, false] {
1750 let full = if base {
1751 base_table("tasks")
1752 } else {
1753 visible_table("tasks")
1754 };
1755 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
1756 for row_id in [
1757 "1",
1758 "01",
1759 "1.0",
1760 "1.0000000000000002",
1761 "+1",
1762 "1e0",
1763 " 1",
1764 "-1",
1765 "0",
1766 "9223372036854775807",
1767 "-9223372036854775808",
1768 "true",
1769 "false",
1770 "",
1771 "nul\u{0}id",
1772 "é",
1773 "null",
1774 "{\"a\":1}",
1775 ] {
1776 let old_sql =
1777 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
1778 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
1779 let old = client
1780 .conn
1781 .prepare(&old_sql)
1782 .unwrap()
1783 .query_map([row_id], |row| row.get::<_, String>(0))
1784 .unwrap()
1785 .collect::<Result<Vec<_>, _>>()
1786 .unwrap();
1787 let new = client
1788 .conn
1789 .prepare_cached(&new_sql)
1790 .unwrap()
1791 .query_map([row_id], |row| row.get::<_, String>(0))
1792 .unwrap()
1793 .collect::<Result<Vec<_>, _>>()
1794 .unwrap();
1795 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
1796 let mut batch = ChangeAccumulator::default();
1797 assert_eq!(
1798 client.record_row_scopes(&mut batch, "tasks", row_id, base),
1799 !old.is_empty()
1800 );
1801 let expected = old.first().map(|scope| {
1802 BTreeMap::from([(
1803 "tasks".to_owned(),
1804 Some(BTreeSet::from([format!("project:{scope}")])),
1805 )])
1806 });
1807 assert_eq!(batch.tables, expected.unwrap_or_default());
1808 client
1809 .conn
1810 .execute_batch("SAVEPOINT lookup_delete")
1811 .unwrap();
1812 if base {
1813 client.delete_base_row("tasks", row_id).unwrap();
1814 } else {
1815 client.apply_outbox_ops(&[OutboxOp {
1816 upsert: false,
1817 table: "tasks".into(),
1818 row_id: row_id.into(),
1819 base_version: None,
1820 values: None,
1821 }]);
1822 }
1823 let remaining: i64 = client
1824 .conn
1825 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
1826 row.get(0)
1827 })
1828 .unwrap();
1829 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
1830 client
1831 .conn
1832 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
1833 .unwrap();
1834 client.overlay_dirty.set(false);
1835 }
1836 for (predicate, expected) in [
1837 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
1838 (predicate, "SEARCH"),
1839 ] {
1840 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
1841 let details = client
1842 .conn
1843 .prepare(&sql)
1844 .unwrap()
1845 .query_map(["1"], |row| row.get::<_, String>(3))
1846 .unwrap()
1847 .collect::<Result<Vec<_>, _>>()
1848 .unwrap();
1849 assert!(
1850 details.iter().any(|detail| detail.contains(expected)),
1851 "{kind}: {details:?}"
1852 );
1853 }
1854 }
1855 }
1856 }
1857
1858 #[test]
1859 fn primary_keys_reject_every_ineligible_column_type() {
1860 for ty in ["float", "bytes", "crdt", "blob_ref"] {
1864 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1865 "columns":[{"name":"id","type":ty,"nullable":false},
1866 {"name":"project_id","type":"string","nullable":false}],
1867 "scopes":[{"pattern":"project:{project_id}"}]}]});
1868 let error =
1869 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
1870 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
1871 Err(error) => error,
1872 };
1873 assert!(
1874 error.contains("has an ineligible column type (§2.4)"),
1875 "{ty}: {error}"
1876 );
1877 }
1878 for ty in ["string", "integer", "boolean", "json"] {
1879 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1880 "columns":[{"name":"id","type":ty,"nullable":false},
1881 {"name":"project_id","type":"string","nullable":false}],
1882 "scopes":[{"pattern":"project:{project_id}"}]}]});
1883 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
1884 .unwrap_or_else(|error| panic!("{ty}: {error}"));
1885 }
1886 }
1887
1888 #[test]
1889 fn pending_row_reconciliation_matches_full_fifo_replay() {
1890 for unique in [false, true] {
1891 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
1892 "name": name, "primaryKey": "id", "columns": [
1893 {"name":"id", "type":"string", "nullable":false},
1894 {"name":"project_id", "type":"string", "nullable":false},
1895 {"name":"title", "type":"string", "nullable":false}],
1896 "scopes": [{"pattern":"project:{project_id}"}],
1897 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
1898 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
1899 })))});
1900 let mut incremental =
1901 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1902 let mut reference =
1903 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1904 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
1905 for instance in [&mut incremental, &mut reference] {
1906 for table in &tables {
1907 for index in 0..12 {
1908 instance
1909 .write_base_row(
1910 table,
1911 &vec![
1912 Some(ColumnValue::String(format!("row{index}"))),
1913 Some(ColumnValue::String("p0".into())),
1914 Some(ColumnValue::String(format!("base{index}"))),
1915 ],
1916 1,
1917 )
1918 .unwrap();
1919 }
1920 }
1921 instance.rebuild_overlay_if_dirty();
1922 }
1923 for index in 0..80 {
1924 let mutations = (0..3)
1925 .map(|offset| {
1926 let table = tables[(index + offset) % 2].clone();
1927 let id = format!("row{}", (index + offset) % 16);
1928 if (index + offset) % 7 == 0 {
1929 Mutation::Delete {
1930 table,
1931 row_id: id,
1932 base_version: None,
1933 }
1934 } else {
1935 Mutation::Upsert {
1936 table,
1937 values: Map::from_iter([
1938 ("id".into(), Value::from(id)),
1939 ("project_id".into(), Value::from(format!("p{}", index % 3))),
1940 (
1941 "title".into(),
1942 Value::from(format!("needle{}", (index + offset) % 5)),
1943 ),
1944 ]),
1945 base_version: None,
1946 }
1947 }
1948 })
1949 .collect::<Vec<_>>();
1950 for instance in [&mut incremental, &mut reference] {
1951 instance.mutate(mutations.clone()).unwrap();
1952 }
1953 let changes = (0..3)
1956 .map(|offset| {
1957 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
1958 let id = format!("row{}", (index + offset) % 16);
1959 let values = Map::from_iter([
1960 ("id".into(), Value::from(id.clone())),
1961 (
1962 "project_id".into(),
1963 Value::from(format!("p{}", (index + 1) % 3)),
1964 ),
1965 (
1966 "title".into(),
1967 Value::from(format!("remote{index}_{offset}")),
1968 ),
1969 ]);
1970 ssp2::model::Change {
1971 table_index: table_index as u16,
1972 row_id: id.clone(),
1973 op: if (index + offset) % 5 == 0 {
1974 Op::Delete
1975 } else {
1976 Op::Upsert
1977 },
1978 row_version: Some(index as i64 + 2),
1979 scopes: vec![],
1980 row: Some(
1981 encode_row_json(
1982 incremental.schema.table(&tables[table_index]).unwrap(),
1983 &id,
1984 &values,
1985 &incremental.encryption,
1986 )
1987 .unwrap(),
1988 ),
1989 }
1990 })
1991 .collect::<Vec<_>>();
1992 reference.overlay_dirty.set(true);
1993 incremental.apply_commit_frame(&tables, &changes).unwrap();
1994 reference.apply_commit_frame(&tables, &changes).unwrap();
1995 for table in &tables {
1996 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
1997 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
1998 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
1999 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2000 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2001 }
2002 }
2003 assert_eq!(incremental.local_revision(), reference.local_revision());
2004 assert_eq!(
2005 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2006 serde_json::to_value(reference.drain_change_batches()).unwrap()
2007 );
2008 }
2009 assert_eq!(
2010 incremental.overlay_rebuild_count.get(),
2011 if unique { 81 } else { 21 }
2012 );
2013 assert_eq!(reference.overlay_rebuild_count.get(), 81);
2014 }
2015 }
2016
2017 #[test]
2018 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2019 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2020 "columns":[{"name":"id", "type":"string", "nullable":false},
2021 {"name":"title", "type":"string", "nullable":false}],
2022 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2023 let mut client =
2024 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2025 for (id, title) in [("a", "original"), ("b", "occupied")] {
2026 client
2027 .write_base_row(
2028 "tasks",
2029 &vec![
2030 Some(ColumnValue::String(id.into())),
2031 Some(ColumnValue::String(title.into())),
2032 ],
2033 1,
2034 )
2035 .unwrap();
2036 }
2037 client.rebuild_overlay_if_dirty();
2038 let pending = client
2039 .mutate(vec![Mutation::Upsert {
2040 table: "tasks".into(),
2041 values: Map::from_iter([
2042 ("id".into(), Value::from("a")),
2043 ("title".into(), Value::from("occupied")),
2044 ]),
2045 base_version: None,
2046 }])
2047 .unwrap();
2048 assert_eq!(
2049 client
2050 .conn
2051 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2052 .get::<_, String>(
2053 0
2054 ))
2055 .unwrap(),
2056 "original"
2057 );
2058 for (title, expected) in [("available", "occupied"), ("occupied", "original")] {
2059 let change = ssp2::model::Change {
2060 table_index: 0,
2061 row_id: "b".into(),
2062 op: Op::Upsert,
2063 row_version: Some(2),
2064 scopes: vec![],
2065 row: Some(
2066 encode_row_json(
2067 client.schema.table("tasks").unwrap(),
2068 "b",
2069 &Map::from_iter([
2070 ("id".into(), Value::from("b")),
2071 ("title".into(), Value::from(title)),
2072 ]),
2073 &client.encryption,
2074 )
2075 .unwrap(),
2076 ),
2077 };
2078 client
2079 .apply_commit_frame(&["tasks".into()], &[change])
2080 .unwrap();
2081 assert_eq!(
2082 client
2083 .conn
2084 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2085 .get::<_, String>(
2086 0
2087 ))
2088 .unwrap(),
2089 expected
2090 );
2091 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2092 }
2093 assert_eq!(client.overlay_rebuild_count.get(), 3);
2094 }
2095
2096 #[test]
2097 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2098 for failure in ["visible", "revision", "commit"] {
2099 let mut client = client();
2100 let id = client
2101 .mutate(vec![Mutation::Upsert {
2102 table: "tasks".into(),
2103 values: Map::from_iter([
2104 ("id".into(), Value::from("t1")),
2105 ("project_id".into(), Value::from("pending")),
2106 ]),
2107 base_version: None,
2108 }])
2109 .unwrap();
2110 client.drain_change_batches();
2111 let revision = client.local_revision();
2112 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2113 let trigger = match failure {
2114 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2115 "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"),
2116 _ => "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(),
2117 };
2118 client.conn.execute_batch(&trigger).unwrap();
2119 let change = ssp2::model::Change {
2120 table_index: 0,
2121 row_id: "t1".into(),
2122 op: Op::Upsert,
2123 row_version: Some(2),
2124 scopes: vec![],
2125 row: Some(
2126 encode_row_json(
2127 client.schema.table("tasks").unwrap(),
2128 "t1",
2129 &Map::from_iter([
2130 ("id".into(), Value::from("t1")),
2131 ("project_id".into(), Value::from("remote")),
2132 ]),
2133 &client.encryption,
2134 )
2135 .unwrap(),
2136 ),
2137 };
2138 assert!(client
2139 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
2140 .is_err());
2141 assert!(client.conn.is_autocommit());
2142 assert!(!client.overlay_dirty.get());
2143 assert_eq!(client.local_revision(), revision);
2144 assert!(client.drain_change_batches().is_empty());
2145 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2146 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2147 assert!(client
2148 .query("SELECT * FROM _syncular_base_tasks", &[])
2149 .unwrap()
2150 .is_empty());
2151 client
2152 .conn
2153 .execute_batch("DROP TRIGGER fail_pending")
2154 .unwrap();
2155 client
2156 .apply_commit_frame(&["tasks".into()], &[change])
2157 .unwrap();
2158 assert_eq!(client.local_revision(), revision + 1);
2159 assert_eq!(client.pending_commit_ids(), vec![id]);
2160 let mut expected = before[0].clone();
2163 expected.insert("_syncular_version".to_owned(), Value::from(2));
2164 assert_eq!(
2165 client.query("SELECT * FROM tasks", &[]).unwrap(),
2166 vec![expected]
2167 );
2168 assert_eq!(
2169 client
2170 .conn
2171 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2172 .get::<_, String>(0))
2173 .unwrap(),
2174 "remote"
2175 );
2176 }
2177 }
2178
2179 #[test]
2180 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2181 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2182 "columns": [
2183 {"name":"id", "type":"string", "nullable":false},
2184 {"name":"project_id", "type":"string", "nullable":false},
2185 {"name":"title", "type":"string", "nullable":false}],
2186 "scopes": [{"pattern":"project:{project_id}"}],
2187 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2188 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2189 let mut incremental =
2190 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2191 let mut reference =
2192 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2193 for instance in [&mut incremental, &mut reference] {
2194 for index in 0..16 {
2195 instance
2196 .write_base_row(
2197 "tasks",
2198 &vec![
2199 Some(ColumnValue::String(format!("task{index}"))),
2200 Some(ColumnValue::String("p0".to_owned())),
2201 Some(ColumnValue::String(format!("base{index}"))),
2202 ],
2203 1,
2204 )
2205 .unwrap();
2206 }
2207 instance.rebuild_overlay_if_dirty();
2208 instance.overlay_rebuild_count.set(0);
2209 }
2210 for index in 0..100 {
2211 let mutations = (0..1 + index % 4)
2212 .map(|offset| {
2213 let id = format!("task{}", (index + offset) % 24);
2214 if (index + offset) % 7 == 0 {
2215 Mutation::Delete {
2216 table: "tasks".to_owned(),
2217 row_id: id,
2218 base_version: None,
2219 }
2220 } else {
2221 Mutation::Upsert {
2222 table: "tasks".to_owned(),
2223 values: Map::from_iter([
2224 ("id".to_owned(), Value::from(id)),
2225 (
2226 "project_id".to_owned(),
2227 Value::from(format!("p{}", index % 2)),
2228 ),
2229 (
2230 "title".to_owned(),
2231 Value::from(format!("needle{}", (index + offset) % 5)),
2232 ),
2233 ]),
2234 base_version: None,
2235 }
2236 }
2237 })
2238 .collect::<Vec<_>>();
2239 reference.overlay_dirty.set(true);
2240 incremental.mutate(mutations.clone()).unwrap();
2241 reference.mutate(mutations).unwrap();
2242 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2243 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2244 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2245 }
2246 assert_eq!(incremental.local_revision(), reference.local_revision());
2247 assert_eq!(
2248 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2249 serde_json::to_value(reference.drain_change_batches()).unwrap()
2250 );
2251 }
2252 assert_eq!(incremental.outbox.len(), 100);
2253 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2254 assert_eq!(reference.overlay_rebuild_count.get(), 100);
2255 }
2256
2257 #[test]
2258 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2259 for fail_at in ["outbox", "revision"] {
2260 let mut client = client();
2261 let values = Map::from_iter([
2262 ("id".to_owned(), Value::from("t1")),
2263 ("project_id".to_owned(), Value::from("p1")),
2264 ]);
2265 let mutation = Mutation::Upsert {
2266 table: "tasks".to_owned(),
2267 values,
2268 base_version: None,
2269 };
2270 let trigger = if fail_at == "outbox" {
2271 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2272 } else {
2273 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2274 };
2275 client.conn.execute_batch(&trigger).unwrap();
2276 let revision = client.local_revision();
2277 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2278 assert_eq!(client.local_revision(), revision);
2279 assert!(client.outbox.is_empty());
2280 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2281 assert_eq!(
2282 client
2283 .conn
2284 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2285 .get::<_, i64>(0))
2286 .unwrap(),
2287 0
2288 );
2289 assert!(client.drain_change_batches().is_empty());
2290 assert!(!client.overlay_dirty.get());
2291 client
2292 .conn
2293 .execute_batch("DROP TRIGGER fail_append")
2294 .unwrap();
2295 client.mutate(vec![mutation]).unwrap();
2296 assert_eq!(client.outbox.len(), 1);
2297 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2298 }
2299 }
2300
2301 #[test]
2302 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2303 let mut client = client();
2304 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2305 let values = Map::from_iter([
2306 ("id".to_owned(), Value::from("t1")),
2307 ("project_id".to_owned(), Value::from("p1")),
2308 ]);
2309 let payload = encode_row_json(
2310 client.schema.table("tasks").unwrap(),
2311 "t1",
2312 &values,
2313 &client.encryption,
2314 )
2315 .unwrap();
2316 let first = ssp2::model::Change {
2317 table_index: 0,
2318 row_id: "t1".to_owned(),
2319 op: Op::Upsert,
2320 row_version: Some(1),
2321 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2322 row: Some(payload),
2323 };
2324 let mut invalid = first.clone();
2325 invalid.table_index = 1;
2326 let revision = client.local_revision();
2327 client.begin_observation("failed_mirror").unwrap();
2328 assert!(client
2329 .apply_commit_changes(&tables, &[first, invalid])
2330 .is_err());
2331 client.rollback_observation("failed_mirror");
2332 client.rebuild_overlay_if_dirty();
2333 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2334 assert_eq!(
2335 client
2336 .conn
2337 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2338 .get::<_, i64>(
2339 0
2340 ))
2341 .unwrap(),
2342 0
2343 );
2344 assert_eq!(client.local_revision(), revision);
2345 assert!(client.drain_change_batches().is_empty());
2346 }
2347
2348 #[test]
2349 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2350 for failure in ["journal", "revision", "commit", "later-frame"] {
2351 let mut client = client();
2352 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2353 for id in ["first", "second"] {
2354 client
2355 .mutate(vec![Mutation::Upsert {
2356 table: "tasks".to_owned(),
2357 values: Map::from_iter([
2358 ("id".to_owned(), Value::from(id)),
2359 ("project_id".to_owned(), Value::from("p1")),
2360 ]),
2361 base_version: None,
2362 }])
2363 .unwrap();
2364 }
2365 let (_, meta) = client.build_request(false);
2366 client
2368 .mutate(vec![Mutation::Upsert {
2369 table: "tasks".to_owned(),
2370 values: Map::from_iter([
2371 ("id".to_owned(), Value::from("later")),
2372 ("project_id".to_owned(), Value::from("p1")),
2373 ]),
2374 base_version: None,
2375 }])
2376 .unwrap();
2377 let ids = client.pending_commit_ids();
2378 let rows = client
2379 .query("SELECT * FROM tasks ORDER BY id", &[])
2380 .unwrap();
2381 let revision = client.local_revision();
2382 client.drain_change_batches();
2383 if failure != "later-frame" {
2384 client.conn.execute_batch(match failure {
2385 "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",
2386 "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",
2387 _ => "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",
2388 }).unwrap();
2389 }
2390 let mut response = Message {
2391 wire_version: WIRE_VERSION,
2392 msg_kind: MsgKind::Response,
2393 frames: vec![Frame::RespHeader {
2394 required_schema_version: None,
2395 latest_schema_version: None,
2396 log_epoch: Some("epoch-1".to_owned()),
2397 reset_required: Some(false),
2398 }],
2399 };
2400 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2401 response.frames.push(Frame::PushResult {
2402 client_commit_id: id.to_owned(),
2403 status: PushStatus::Applied,
2404 commit_seq: Some(1),
2405 results: vec![OpResult::Applied { op_index: 0 }],
2406 });
2407 }
2408 if failure == "later-frame" {
2409 response.frames.push(Frame::Error {
2410 code: "sync.invalid_request".to_owned(),
2411 message: "later error".to_owned(),
2412 category: "protocol".to_owned(),
2413 retryable: false,
2414 recommended_action: "retry".to_owned(),
2415 details: None,
2416 });
2417 }
2418 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2419 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2420 assert!(
2421 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2422 );
2423 if failure == "later-frame" {
2424 assert_eq!(client.pending_commit_ids(), ids[2..]);
2425 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2426 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2427 let batches = client.drain_change_batches();
2428 assert_eq!(batches.len(), 1);
2429 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2430 assert_eq!(client.local_revision(), revision + 1);
2431 response.frames.pop();
2432 } else {
2433 assert_eq!(client.pending_commit_ids(), ids);
2434 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2435 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2436 assert_eq!(client.local_revision(), revision);
2437 assert!(client.drain_change_batches().is_empty());
2438 assert_eq!(
2439 client
2440 .query("SELECT * FROM tasks ORDER BY id", &[])
2441 .unwrap(),
2442 rows
2443 );
2444 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2445 }
2446 for frame in &mut response.frames {
2447 if let Frame::PushResult { status, .. } = frame {
2448 *status = PushStatus::Cached;
2449 }
2450 }
2451 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2452 else {
2453 panic!("retry failed");
2454 };
2455 assert_eq!(
2456 report.applied,
2457 if failure == "later-frame" {
2458 vec![]
2459 } else {
2460 ids[..2].to_vec()
2461 }
2462 );
2463 assert_eq!(client.pending_commit_ids(), ids[2..]);
2464 let batches = client.drain_change_batches();
2465 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2466 if let Some(batch) = batches.first() {
2467 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2468 }
2469 assert_eq!(client.local_revision(), revision + 1);
2470 }
2471 }
2472
2473 #[test]
2474 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2475 for failure in ["revision", "journal", "commit"] {
2476 for status in ["applied", "cached", "rejected", "conflict"] {
2477 let mut client = client();
2478 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2479 for id in ["first", "later"] {
2480 client
2481 .mutate(vec![Mutation::Upsert {
2482 table: "tasks".to_owned(),
2483 values: Map::from_iter([
2484 ("id".to_owned(), Value::from(id)),
2485 ("project_id".to_owned(), Value::from("p1")),
2486 ]),
2487 base_version: None,
2488 }])
2489 .unwrap();
2490 }
2491 let (_, meta) = client.build_request(false);
2492 let ids = client.pending_commit_ids();
2493 let rows = client
2494 .query("SELECT * FROM tasks ORDER BY id", &[])
2495 .unwrap();
2496 let revision = client.local_revision();
2497 client.drain_change_batches();
2498 let (push_status, results) = match status {
2499 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2500 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2501 "conflict" => (
2502 PushStatus::Rejected,
2503 vec![OpResult::Conflict {
2504 op_index: 0,
2505 code: "sync.version_conflict".to_owned(),
2506 message: "conflict".to_owned(),
2507 server_version: 1,
2508 server_row: encode_row_json(
2509 client.schema.table("tasks").unwrap(),
2510 "first",
2511 &Map::from_iter([
2512 ("id".to_owned(), Value::from("first")),
2513 ("project_id".to_owned(), Value::from("p1")),
2514 ]),
2515 &client.encryption,
2516 )
2517 .unwrap(),
2518 conflict_columns: Vec::new(),
2519 }],
2520 ),
2521 _ => (
2522 PushStatus::Rejected,
2523 vec![OpResult::Error {
2524 op_index: 0,
2525 code: "sync.validation_failed".to_owned(),
2526 message: "rejected".to_owned(),
2527 retryable: false,
2528 }],
2529 ),
2530 };
2531 let response = Message {
2532 wire_version: WIRE_VERSION,
2533 msg_kind: MsgKind::Response,
2534 frames: vec![
2535 Frame::RespHeader {
2536 required_schema_version: None,
2537 latest_schema_version: None,
2538 log_epoch: Some("epoch-1".to_owned()),
2539 reset_required: Some(false),
2540 },
2541 Frame::PushResult {
2542 client_commit_id: ids[0].clone(),
2543 status: push_status,
2544 commit_seq: Some(1),
2545 results,
2546 },
2547 ],
2548 };
2549 client.conn.execute_batch(match failure {
2550 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2551 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2552 _ => "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",
2553 }).unwrap();
2554 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2555 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2556 assert_eq!(
2557 client.pending_commit_ids(),
2558 ids,
2559 "{failure}/{status}: pending identity"
2560 );
2561 assert_eq!(
2562 client.local_revision(),
2563 revision,
2564 "{failure}/{status}: revision"
2565 );
2566 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2567 assert!(client.conflicts().is_empty());
2568 assert!(client.rejections().is_empty());
2569 assert!(client.drain_change_batches().is_empty());
2570 assert_eq!(
2571 client
2572 .query("SELECT * FROM tasks ORDER BY id", &[])
2573 .unwrap(),
2574 rows
2575 );
2576 assert!(
2577 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2578 "{failure}/{status}: failed round"
2579 );
2580 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2581 assert!(matches!(
2582 client.process_response(&mut transport, response, &meta),
2583 SyncOutcome::Ok(_)
2584 ));
2585 assert_eq!(client.pending_commit_ids(), ids[1..]);
2586 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2587 assert_eq!(client.drain_change_batches().len(), 1);
2588 }
2589 }
2590 }
2591
2592 #[test]
2593 fn mixed_push_results_reconcile_and_prune_once_per_response() {
2594 let mut client = client();
2595 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2596 for index in 0..4 {
2597 client
2598 .mutate(vec![Mutation::Upsert {
2599 table: "tasks".to_owned(),
2600 values: Map::from_iter([
2601 ("id".to_owned(), Value::from(format!("task-{index}"))),
2602 ("project_id".to_owned(), Value::from("project-1")),
2603 ]),
2604 base_version: None,
2605 }])
2606 .expect("queue commit");
2607 }
2608
2609 let (_, request_meta) = client.build_request(false);
2610 let ids = &request_meta.pushed_ids;
2611 assert_eq!(ids.len(), 4);
2612 let response = Message {
2613 wire_version: WIRE_VERSION,
2614 msg_kind: MsgKind::Response,
2615 frames: vec![
2616 Frame::RespHeader {
2617 required_schema_version: None,
2618 latest_schema_version: None,
2619 log_epoch: Some("epoch-1".to_owned()),
2620 reset_required: Some(false),
2621 },
2622 Frame::PushResult {
2623 client_commit_id: ids[0].clone(),
2624 status: PushStatus::Applied,
2625 commit_seq: Some(1),
2626 results: vec![OpResult::Applied { op_index: 0 }],
2627 },
2628 Frame::PushResult {
2629 client_commit_id: ids[1].clone(),
2630 status: PushStatus::Cached,
2631 commit_seq: Some(2),
2632 results: vec![OpResult::Applied { op_index: 0 }],
2633 },
2634 Frame::PushResult {
2635 client_commit_id: ids[2].clone(),
2636 status: PushStatus::Rejected,
2637 commit_seq: None,
2638 results: vec![OpResult::Error {
2639 op_index: 0,
2640 code: "sync.validation_failed".to_owned(),
2641 message: "rejected".to_owned(),
2642 retryable: false,
2643 }],
2644 },
2645 Frame::PushResult {
2646 client_commit_id: ids[3].clone(),
2647 status: PushStatus::Rejected,
2648 commit_seq: None,
2649 results: vec![OpResult::Error {
2650 op_index: 0,
2651 code: "sync.idempotency_cache_miss".to_owned(),
2652 message: "retry".to_owned(),
2653 retryable: true,
2654 }],
2655 },
2656 ],
2657 };
2658 let mut transport =
2659 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2660
2661 client.overlay_rebuild_count.set(0);
2662 client.outcome_prune_count.set(0);
2663 let outcome = client.process_response(&mut transport, response, &request_meta);
2664 let SyncOutcome::Ok(report) = outcome else {
2665 panic!("mixed push-result response failed");
2666 };
2667
2668 assert_eq!(report.applied, ids[..2]);
2669 assert_eq!(report.rejected, ids[2..3]);
2670 assert_eq!(report.retryable, ids[3..4]);
2671 assert_eq!(client.pending_commit_ids(), ids[3..4]);
2672 assert_eq!(
2673 client
2674 .query("SELECT id FROM tasks ORDER BY id", &[])
2675 .expect("query visible overlay"),
2676 vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2677 );
2678 assert_eq!(client.overlay_rebuild_count.get(), 1);
2679 assert_eq!(client.outcome_prune_count.get(), 1);
2680 }
2681
2682 #[test]
2683 fn secondary_unique_collision_preserves_existing_synced_row() {
2684 let client = SyncClient::new(
2685 "unique-upsert-test".to_owned(),
2686 &json!({
2687 "version": 1,
2688 "tables": [{
2689 "name": "tasks",
2690 "primaryKey": "id",
2691 "columns": [
2692 { "name": "id", "type": "string", "nullable": false },
2693 { "name": "project_id", "type": "string", "nullable": false },
2694 { "name": "title", "type": "string", "nullable": false }
2695 ],
2696 "scopes": [{ "pattern": "project:{project_id}" }],
2697 "indexes": [{
2698 "name": "tasks_by_project_title",
2699 "columns": ["project_id", "title"],
2700 "unique": true
2701 }]
2702 }]
2703 }),
2704 ClientLimits::default(),
2705 )
2706 .expect("test client");
2707 let table = client.schema.table("tasks").expect("tasks table");
2708 let sql = client.insert_row_sql(&base_table("tasks"), table);
2709
2710 client
2711 .conn
2712 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2713 .expect("insert first row");
2714 client
2715 .conn
2716 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
2717 .expect("update same primary key");
2718 client
2719 .conn
2720 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
2721 .expect("insert second row");
2722 assert!(client
2723 .conn
2724 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
2725 .is_err());
2726
2727 let rows = client
2728 .conn
2729 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
2730 .expect("prepare rows")
2731 .query_map([], |row| {
2732 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2733 })
2734 .expect("query rows")
2735 .collect::<Result<Vec<_>, _>>()
2736 .expect("collect rows");
2737 assert_eq!(
2738 rows,
2739 vec![
2740 ("t1".to_owned(), "updated".to_owned()),
2741 ("t2".to_owned(), "original".to_owned())
2742 ]
2743 );
2744 }
2745
2746 #[test]
2747 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
2748 let path = std::env::temp_dir().join(format!(
2749 "syncular-startup-intent-{}.db",
2750 uuid::Uuid::new_v4()
2751 ));
2752 let schema = json!({
2753 "version": 1,
2754 "tables": [{
2755 "name": "tasks",
2756 "primaryKey": "id",
2757 "columns": [
2758 { "name": "id", "type": "string", "nullable": false },
2759 { "name": "project_id", "type": "string", "nullable": false }
2760 ],
2761 "scopes": [{ "pattern": "project:{project_id}" }]
2762 }]
2763 });
2764
2765 {
2766 let mut first = SyncClient::open_path_with_identity(
2767 None,
2768 &schema,
2769 ClientLimits::default(),
2770 path.to_str().expect("UTF-8 temp path"),
2771 )
2772 .expect("first open");
2773 first
2774 .set_window(
2775 &WindowBase {
2776 table: "tasks".to_owned(),
2777 variable: "project_id".to_owned(),
2778 fixed_scopes: Vec::new(),
2779 params: None,
2780 },
2781 &["persisted".to_owned()],
2782 )
2783 .expect("persist window");
2784 }
2785
2786 let mut reopened = SyncClient::open_path_with_identity(
2787 None,
2788 &schema,
2789 ClientLimits::default(),
2790 path.to_str().expect("UTF-8 temp path"),
2791 )
2792 .expect("reopen");
2793 assert!(reopened.sync_needed());
2794 assert!(matches!(
2795 reopened.drain_sync_intents().as_slice(),
2796 [SyncIntent::Interactive]
2797 ));
2798 drop(reopened);
2799 std::fs::remove_file(path).expect("remove temp database");
2800 }
2801
2802 #[test]
2803 fn reopening_preserves_immutable_subscription_identity_and_progress() {
2804 let path = std::env::temp_dir().join(format!(
2805 "syncular-subscription-identity-{}.db",
2806 uuid::Uuid::new_v4()
2807 ));
2808 let schema = json!({
2809 "version": 1,
2810 "tables": [
2811 {
2812 "name": "tasks",
2813 "primaryKey": "id",
2814 "columns": [
2815 { "name": "id", "type": "string", "nullable": false },
2816 { "name": "project_id", "type": "string", "nullable": false }
2817 ],
2818 "scopes": [{ "pattern": "project:{project_id}" }]
2819 },
2820 {
2821 "name": "docs",
2822 "primaryKey": "id",
2823 "columns": [
2824 { "name": "id", "type": "string", "nullable": false },
2825 { "name": "org_id", "type": "string", "nullable": false },
2826 { "name": "project_id", "type": "string", "nullable": false }
2827 ],
2828 "scopes": [
2829 { "pattern": "org:{org_id}" },
2830 { "pattern": "project:{projectId}", "column": "project_id" }
2831 ]
2832 }
2833 ]
2834 });
2835
2836 {
2837 let mut first = SyncClient::open_path_with_identity(
2838 None,
2839 &schema,
2840 ClientLimits::default(),
2841 path.to_str().expect("UTF-8 temp path"),
2842 )
2843 .expect("first open");
2844 first
2845 .subscribe(
2846 "stable-subscription".to_owned(),
2847 "tasks".to_owned(),
2848 vec![(
2849 "project_id".to_owned(),
2850 vec!["p2".to_owned(), "p1".to_owned()],
2851 )],
2852 Some(r#"{"view":"v1"}"#.to_owned()),
2853 )
2854 .expect("persist subscription");
2855 let persisted = {
2856 let subscription = first
2857 .subs
2858 .iter_mut()
2859 .find(|subscription| subscription.id == "stable-subscription")
2860 .expect("subscription");
2861 subscription.cursor = 41;
2862 subscription.bootstrap_state = Some("resume-token".to_owned());
2863 subscription.effective = Some(vec![(
2864 "project_id".to_owned(),
2865 vec!["p1".to_owned(), "p2".to_owned()],
2866 )]);
2867 subscription.synced_once = true;
2868 subscription.clone()
2869 };
2870 first.persist_sub(&persisted).unwrap();
2871 }
2872
2873 let mut reopened = SyncClient::open_path_with_identity(
2874 None,
2875 &schema,
2876 ClientLimits::default(),
2877 path.to_str().expect("UTF-8 temp path"),
2878 )
2879 .expect("reopen");
2880 let progress = reopened
2881 .subscription_state("stable-subscription")
2882 .expect("persisted state");
2883 assert_eq!(progress.cursor, 41);
2884 assert!(progress.has_resume_token);
2885
2886 reopened
2887 .subscribe(
2888 "stable-subscription".to_owned(),
2889 "tasks".to_owned(),
2890 vec![(
2891 "project_id".to_owned(),
2892 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
2893 )],
2894 Some(r#"{"view":"v1"}"#.to_owned()),
2895 )
2896 .expect("canonical intent is idempotent");
2897 assert_eq!(
2898 reopened
2899 .subscription_state("stable-subscription")
2900 .expect("unchanged state")
2901 .cursor,
2902 progress.cursor
2903 );
2904
2905 for (table, scopes, params) in [
2906 (
2907 "tasks",
2908 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2909 Some(r#"{"view":"v1"}"#.to_owned()),
2910 ),
2911 (
2912 "tasks",
2913 vec![(
2914 "project_id".to_owned(),
2915 vec!["p2".to_owned(), "p1".to_owned()],
2916 )],
2917 Some(r#"{"view":"v2"}"#.to_owned()),
2918 ),
2919 (
2920 "docs",
2921 vec![
2922 ("org_id".to_owned(), vec!["o1".to_owned()]),
2923 ("projectId".to_owned(), vec!["p1".to_owned()]),
2924 ],
2925 Some(r#"{"view":"v1"}"#.to_owned()),
2926 ),
2927 ] {
2928 let error = reopened
2929 .subscribe(
2930 "stable-subscription".to_owned(),
2931 table.to_owned(),
2932 scopes,
2933 params,
2934 )
2935 .expect_err("identity rebind must fail");
2936 assert!(error.starts_with("client.subscription_intent_mismatch:"));
2937 assert_eq!(
2938 reopened
2939 .subscription_state("stable-subscription")
2940 .expect("unchanged state")
2941 .cursor,
2942 progress.cursor
2943 );
2944 }
2945
2946 drop(reopened);
2947 std::fs::remove_file(path).expect("remove temp database");
2948 }
2949
2950 #[test]
2951 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
2952 let path = std::env::temp_dir().join(format!(
2953 "syncular-satisfied-schema-floor-{}.db",
2954 uuid::Uuid::new_v4()
2955 ));
2956 let schema = json!({
2957 "version": 23,
2958 "tables": [{
2959 "name": "tasks",
2960 "primaryKey": "id",
2961 "columns": [
2962 { "name": "id", "type": "string", "nullable": false },
2963 { "name": "project_id", "type": "string", "nullable": false }
2964 ],
2965 "scopes": [{ "pattern": "project:{project_id}" }]
2966 }]
2967 });
2968
2969 {
2970 let mut first = SyncClient::open_path_with_identity(
2971 None,
2972 &schema,
2973 ClientLimits::default(),
2974 path.to_str().expect("UTF-8 temp path"),
2975 )
2976 .expect("first open");
2977 first
2978 .subscribe(
2979 "tasks".to_owned(),
2980 "tasks".to_owned(),
2981 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2982 None,
2983 )
2984 .expect("persist subscription");
2985 first.set_schema_floor(Some(SchemaFloor {
2986 required_schema_version: Some(22),
2987 latest_schema_version: Some(22),
2988 }));
2989 }
2990
2991 let mut reopened = SyncClient::open_path_with_identity(
2992 None,
2993 &schema,
2994 ClientLimits::default(),
2995 path.to_str().expect("UTF-8 temp path"),
2996 )
2997 .expect("reopen");
2998 assert!(reopened.schema_floor().is_none());
2999 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3000 assert!(reopened.sync_needed());
3001 assert!(matches!(
3002 reopened.drain_sync_intents().as_slice(),
3003 [SyncIntent::Interactive]
3004 ));
3005 drop(reopened);
3006 std::fs::remove_file(path).expect("remove temp database");
3007 }
3008
3009 #[test]
3010 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3011 let path = std::env::temp_dir().join(format!(
3012 "syncular-unsatisfied-schema-floor-{}.db",
3013 uuid::Uuid::new_v4()
3014 ));
3015 let schema = json!({
3016 "version": 1,
3017 "tables": [{
3018 "name": "tasks",
3019 "primaryKey": "id",
3020 "columns": [
3021 { "name": "id", "type": "string", "nullable": false },
3022 { "name": "project_id", "type": "string", "nullable": false }
3023 ],
3024 "scopes": [{ "pattern": "project:{project_id}" }]
3025 }]
3026 });
3027
3028 {
3029 let mut first = SyncClient::open_path_with_identity(
3030 None,
3031 &schema,
3032 ClientLimits::default(),
3033 path.to_str().expect("UTF-8 temp path"),
3034 )
3035 .expect("first open");
3036 first
3037 .subscribe(
3038 "tasks".to_owned(),
3039 "tasks".to_owned(),
3040 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3041 None,
3042 )
3043 .expect("persist subscription");
3044 first.set_schema_floor(Some(SchemaFloor {
3045 required_schema_version: Some(2),
3046 latest_schema_version: Some(2),
3047 }));
3048 }
3049
3050 let reopened = SyncClient::open_path_with_identity(
3051 None,
3052 &schema,
3053 ClientLimits::default(),
3054 path.to_str().expect("UTF-8 temp path"),
3055 )
3056 .expect("reopen");
3057 assert_eq!(
3058 reopened.schema_floor(),
3059 Some(&SchemaFloor {
3060 required_schema_version: Some(2),
3061 latest_schema_version: Some(2),
3062 })
3063 );
3064 assert!(!reopened.sync_needed());
3065 drop(reopened);
3066 std::fs::remove_file(path).expect("remove temp database");
3067 }
3068
3069 #[test]
3070 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3071 let path = std::env::temp_dir().join(format!(
3072 "syncular-indexed-column-bump-{}.db",
3073 uuid::Uuid::new_v4()
3074 ));
3075 let old_schema = json!({
3076 "version": 1,
3077 "tables": [
3078 {
3079 "name": "tasks",
3080 "primaryKey": "id",
3081 "columns": [
3082 { "name": "id", "type": "string", "nullable": false },
3083 { "name": "project_id", "type": "string", "nullable": false }
3084 ],
3085 "scopes": [{ "pattern": "project:{project_id}" }]
3086 },
3087 {
3088 "name": "legacy",
3089 "primaryKey": "id",
3090 "columns": [
3091 { "name": "id", "type": "string", "nullable": false },
3092 { "name": "project_id", "type": "string", "nullable": false }
3093 ],
3094 "scopes": [{ "pattern": "project:{project_id}" }]
3095 }
3096 ]
3097 });
3098 {
3099 let mut first = SyncClient::open_path_with_identity(
3100 None,
3101 &old_schema,
3102 ClientLimits::default(),
3103 path.to_str().expect("UTF-8 temp path"),
3104 )
3105 .expect("open old schema");
3106 first
3107 .subscribe(
3108 "legacy-sub".to_owned(),
3109 "legacy".to_owned(),
3110 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3111 None,
3112 )
3113 .expect("persist legacy subscription");
3114 }
3115
3116 let new_schema = json!({
3117 "version": 2,
3118 "tables": [{
3119 "name": "tasks",
3120 "primaryKey": "id",
3121 "columns": [
3122 { "name": "id", "type": "string", "nullable": false },
3123 { "name": "project_id", "type": "string", "nullable": false },
3124 { "name": "facility_membership_id", "type": "string", "nullable": true }
3125 ],
3126 "scopes": [{ "pattern": "project:{project_id}" }],
3127 "indexes": [{
3128 "name": "tasks_by_membership",
3129 "columns": ["project_id", "facility_membership_id"],
3130 "unique": false
3131 }]
3132 }]
3133 });
3134 let upgraded = SyncClient::open_path_with_identity(
3135 None,
3136 &new_schema,
3137 ClientLimits::default(),
3138 path.to_str().expect("UTF-8 temp path"),
3139 )
3140 .expect("open upgraded schema");
3141 let columns = upgraded
3142 .conn
3143 .prepare("PRAGMA table_info(tasks)")
3144 .expect("prepare columns")
3145 .query_map([], |row| row.get::<_, String>(1))
3146 .expect("query columns")
3147 .collect::<Result<Vec<_>, _>>()
3148 .expect("collect columns");
3149 assert!(columns.contains(&"facility_membership_id".to_owned()));
3150 let index_count: i64 = upgraded
3151 .conn
3152 .query_row(
3153 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3154 [],
3155 |row| row.get(0),
3156 )
3157 .expect("query index");
3158 assert_eq!(index_count, 1);
3159 assert!(upgraded.subscription_state("legacy-sub").is_none());
3160 drop(upgraded);
3161 std::fs::remove_file(path).expect("remove temp database");
3162 }
3163
3164 #[test]
3165 fn migrates_pre_envelope_outcome_journal_additively() {
3166 let path = std::env::temp_dir().join(format!(
3167 "syncular-outcome-migration-{}.db",
3168 uuid::Uuid::new_v4()
3169 ));
3170 let conn = Connection::open(&path).expect("open legacy database");
3171 conn.execute_batch(
3172 "CREATE TABLE _syncular_commit_outcomes (
3173 seq INTEGER PRIMARY KEY AUTOINCREMENT,
3174 client_commit_id TEXT NOT NULL UNIQUE,
3175 status TEXT NOT NULL,
3176 recorded_at_ms INTEGER NOT NULL,
3177 results_json TEXT NOT NULL,
3178 resolution TEXT NOT NULL DEFAULT 'active',
3179 resolved_at_ms INTEGER,
3180 replacement_client_commit_id TEXT);",
3181 )
3182 .expect("create legacy outcome journal");
3183 drop(conn);
3184 let schema = json!({
3185 "version": 1,
3186 "tables": [{
3187 "name": "tasks",
3188 "primaryKey": "id",
3189 "columns": [
3190 { "name": "id", "type": "string", "nullable": false },
3191 { "name": "project_id", "type": "string", "nullable": false }
3192 ],
3193 "scopes": [{ "pattern": "project:{project_id}" }]
3194 }]
3195 });
3196 let client = SyncClient::open_path(
3197 "migration-native".to_owned(),
3198 &schema,
3199 ClientLimits::default(),
3200 path.to_str().expect("UTF-8 temp path"),
3201 )
3202 .expect("migrate database");
3203 let has_operations = client
3204 .conn
3205 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3206 .expect("prepare table info")
3207 .query_map([], |row| row.get::<_, String>(1))
3208 .expect("query table info")
3209 .filter_map(Result::ok)
3210 .any(|column| column == "operations_json");
3211 assert!(has_operations);
3212 drop(client);
3213 std::fs::remove_file(path).expect("remove temp database");
3214 }
3215
3216 #[test]
3217 fn durable_conflict_outcome_and_resolution_survive_reopen() {
3218 let path = std::env::temp_dir().join(format!(
3219 "syncular-durable-outcome-{}.db",
3220 uuid::Uuid::new_v4()
3221 ));
3222 let schema = json!({
3223 "version": 1,
3224 "tables": [{
3225 "name": "tasks",
3226 "primaryKey": "id",
3227 "columns": [
3228 { "name": "id", "type": "string", "nullable": false },
3229 { "name": "project_id", "type": "string", "nullable": false }
3230 ],
3231 "scopes": [{ "pattern": "project:{project_id}" }]
3232 }]
3233 });
3234 let conflict = ConflictRecord {
3235 client_commit_id: "losing-commit".to_owned(),
3236 op_index: 0,
3237 table: "tasks".to_owned(),
3238 row_id: "t1".to_owned(),
3239 code: "sync.version_conflict".to_owned(),
3240 message: "stale base version".to_owned(),
3241 server_version: 2,
3242 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3243 conflict_columns: vec!["project_id".to_owned()],
3244 operation: Some(CommitOperation {
3245 table: "tasks".to_owned(),
3246 row_id: "t1".to_owned(),
3247 op: "upsert".to_owned(),
3248 base_version: Some(1),
3249 values: None,
3250 }),
3251 };
3252 let failed_operations = vec![
3253 OutboxOp {
3254 upsert: true,
3255 table: "tasks".to_owned(),
3256 row_id: "t1".to_owned(),
3257 base_version: Some(1),
3258 values: None,
3259 },
3260 OutboxOp {
3261 upsert: true,
3262 table: "tasks".to_owned(),
3263 row_id: "status-event-1".to_owned(),
3264 base_version: Some(0),
3265 values: None,
3266 },
3267 ];
3268
3269 {
3270 let mut first = SyncClient::open_path(
3271 "durable-native".to_owned(),
3272 &schema,
3273 ClientLimits::default(),
3274 path.to_str().expect("UTF-8 temp path"),
3275 )
3276 .expect("first open");
3277 first
3278 .begin_observation("test_outcome")
3279 .expect("begin outcome");
3280 first
3281 .persist_commit_outcome(
3282 "losing-commit",
3283 CommitOutcomeStatus::Conflict,
3284 &[CommitOperationOutcome::Conflict {
3285 conflict: conflict.clone(),
3286 }],
3287 Some(&failed_operations),
3288 )
3289 .expect("persist outcome");
3290 first.conflicts.push(conflict);
3291 first
3292 .finish_observation(
3293 "test_outcome",
3294 ChangeAccumulator {
3295 conflicts: true,
3296 outcomes: true,
3297 ..ChangeAccumulator::default()
3298 },
3299 )
3300 .expect("commit outcome");
3301 }
3302
3303 {
3304 let mut reopened = SyncClient::open_path(
3305 "durable-native".to_owned(),
3306 &schema,
3307 ClientLimits::default(),
3308 path.to_str().expect("UTF-8 temp path"),
3309 )
3310 .expect("reopen");
3311 assert_eq!(reopened.conflicts().len(), 1);
3312 assert_eq!(
3313 reopened.conflicts()[0].conflict_columns,
3314 vec!["project_id".to_owned()],
3315 "conflictColumns survives the journal round trip"
3316 );
3317 let outcome = reopened
3318 .commit_outcome("losing-commit")
3319 .expect("read outcome")
3320 .expect("outcome");
3321 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3322 let operations = outcome.operations.expect("aggregate envelope");
3323 assert_eq!(operations.len(), 2);
3324 assert_eq!(operations[1].row_id, "status-event-1");
3325 let resolved = reopened
3326 .resolve_commit_outcome(ResolveCommitOutcomeInput {
3327 client_commit_id: "losing-commit".to_owned(),
3328 resolution: CommitOutcomeResolution::ResolvedKeepServer,
3329 replacement_client_commit_id: None,
3330 })
3331 .expect("resolve");
3332 assert_eq!(
3333 resolved.resolution,
3334 CommitOutcomeResolution::ResolvedKeepServer
3335 );
3336 assert!(reopened.conflicts().is_empty());
3337 }
3338
3339 let reopened = SyncClient::open_path(
3340 "durable-native".to_owned(),
3341 &schema,
3342 ClientLimits::default(),
3343 path.to_str().expect("UTF-8 temp path"),
3344 )
3345 .expect("second reopen");
3346 assert!(reopened.conflicts().is_empty());
3347 assert_eq!(
3348 reopened
3349 .commit_outcome("losing-commit")
3350 .expect("read outcome")
3351 .expect("outcome")
3352 .resolution,
3353 CommitOutcomeResolution::ResolvedKeepServer
3354 );
3355 drop(reopened);
3356 std::fs::remove_file(path).expect("remove temp database");
3357 }
3358
3359 #[test]
3360 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3361 let path =
3362 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3363 let schema = json!({
3364 "version": 1,
3365 "tables": [{
3366 "name": "tasks",
3367 "primaryKey": "id",
3368 "columns": [
3369 { "name": "id", "type": "string", "nullable": false },
3370 { "name": "project_id", "type": "string", "nullable": false }
3371 ],
3372 "scopes": [{ "pattern": "project:{project_id}" }]
3373 }]
3374 });
3375 let mut client = SyncClient::open_path_with_identity(
3376 Some("sidecar-client".to_owned()),
3377 &schema,
3378 ClientLimits::default(),
3379 path.to_str().expect("UTF-8 temp path"),
3380 )
3381 .expect("open owner");
3382 let base = WindowBase {
3383 table: "tasks".to_owned(),
3384 variable: "project_id".to_owned(),
3385 fixed_scopes: Vec::new(),
3386 params: None,
3387 };
3388 client
3389 .set_window(&base, &["one".to_owned()])
3390 .expect("set window");
3391 client
3392 .mutate(vec![Mutation::Upsert {
3393 table: "tasks".to_owned(),
3394 values: Map::from_iter([
3395 ("id".to_owned(), Value::from("t1")),
3396 ("project_id".to_owned(), Value::from("one")),
3397 ]),
3398 base_version: None,
3399 }])
3400 .expect("local mutate");
3401
3402 let coverage = [WindowCoverage {
3403 base,
3404 units: vec!["one".to_owned(), "missing".to_owned()],
3405 }];
3406 let owner = client
3407 .query_snapshot(
3408 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3409 &[],
3410 &coverage,
3411 )
3412 .expect("owner snapshot");
3413 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3414 let sidecar = reader
3415 .query_snapshot(
3416 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3417 &[],
3418 &coverage,
3419 )
3420 .expect("sidecar snapshot");
3421
3422 assert_eq!(sidecar.revision, owner.revision);
3423 assert_eq!(sidecar.rows, owner.rows);
3424 assert_eq!(
3425 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3426 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3427 );
3428 assert_eq!(sidecar.revision, "2");
3429 assert_eq!(sidecar.rows[0]["id"], "t1");
3430 assert_eq!(sidecar.rows[0]["server_version"], -1);
3431 assert!(!sidecar.coverage.complete);
3432 assert_eq!(sidecar.coverage.pending.len(), 1);
3433 assert_eq!(sidecar.coverage.missing.len(), 1);
3434
3435 drop(reader);
3436 drop(client);
3437 std::fs::remove_file(path).expect("remove temp database");
3438 }
3439
3440 #[test]
3441 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3442 let mut client = client();
3443 let image = Connection::open_in_memory().unwrap();
3444 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3445 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3446 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3447 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3448 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3449 let table = client.schema.table("tasks").unwrap().clone();
3450 let scopes = vec![("project_id".into(), vec!["p1".into()])];
3451 assert!(client
3452 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3453 .is_err());
3454 assert!(client.conn.is_autocommit());
3455 assert_eq!(
3456 client
3457 .query("SELECT count(*) AS n FROM tasks", &[])
3458 .unwrap()[0]["n"],
3459 1024
3460 );
3461 assert_eq!(client.drain_change_batches().len(), 1);
3462 image
3463 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3464 .unwrap();
3465 assert!(matches!(
3466 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3467 Ok(2050)
3468 ));
3469 assert_eq!(
3470 client
3471 .query("SELECT count(*) AS n FROM tasks", &[])
3472 .unwrap()[0]["n"],
3473 2050
3474 );
3475 assert_eq!(client.drain_change_batches().len(), 3);
3476 }
3477
3478 #[test]
3479 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3480 let mut client = client();
3481 let base = WindowBase {
3482 table: "tasks".into(),
3483 variable: "project_id".into(),
3484 fixed_scopes: Vec::new(),
3485 params: None,
3486 };
3487 client
3488 .set_window(&base, &["p1".into(), "p2".into()])
3489 .unwrap();
3490 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3491 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3492 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3493 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3494 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3495 client.drain_change_batches();
3496 assert!(client.set_window(&base, &["p2".into()]).is_err());
3497 assert!(client.conn.is_autocommit());
3498 assert_eq!(
3499 client
3500 .query("SELECT count(*) AS n FROM tasks", &[])
3501 .unwrap()[0]["n"],
3502 1027
3503 );
3504 assert_eq!(client.window_state(&base).units, vec!["p2"]);
3505 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
3506 assert_eq!(client.drain_change_batches().len(), 1);
3507 client
3508 .conn
3509 .execute_batch("DROP TRIGGER interrupt_evict")
3510 .unwrap();
3511 client.drain_pending_evictions().unwrap();
3512 assert!(client.load_pending_evictions().unwrap().is_empty());
3513 assert_eq!(
3514 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3515 "held"
3516 );
3517 assert_eq!(client.drain_change_batches().len(), 2);
3518 }
3519
3520 #[test]
3521 fn eviction_marker_failures_roll_back_window_changes() {
3522 let mut client = client();
3523 let base = WindowBase {
3524 table: "tasks".into(),
3525 variable: "project_id".into(),
3526 fixed_scopes: Vec::new(),
3527 params: None,
3528 };
3529 client.set_window(&base, &["p1".into()]).unwrap();
3530 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3531 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3532 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3533 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3534 let revision = client.local_revision();
3535 assert!(client.set_window(&base, &[]).is_err());
3536 assert!(client.conn.is_autocommit());
3537 assert_eq!(client.local_revision(), revision);
3538 assert_eq!(
3539 client
3540 .query("SELECT count(*) AS n FROM tasks", &[])
3541 .unwrap()[0]["n"],
3542 2050
3543 );
3544 assert_eq!(client.window_state(&base).units, vec!["p1"]);
3545 client.conn.execute_batch("DROP TRIGGER fail_marker;
3546 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3547 assert!(client.set_window(&base, &[]).is_err());
3548 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
3549 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3550 assert!(client.drain_pending_evictions().is_err());
3551 assert!(client.conn.is_autocommit());
3552 assert_eq!(
3553 client
3554 .query("SELECT count(*) AS n FROM tasks", &[])
3555 .unwrap()[0]["n"],
3556 2
3557 );
3558 assert!(client.set_window(&base, &["p1".into()]).is_err());
3559 assert!(client.conn.is_autocommit());
3560 assert!(client.window_state(&base).units.is_empty());
3561 assert!(client.subs.is_empty());
3562 assert_eq!(
3563 client
3564 .query(
3565 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
3566 &[]
3567 )
3568 .unwrap()[0]["n"],
3569 1
3570 );
3571 client
3572 .conn
3573 .execute_batch("DROP TRIGGER fail_marker")
3574 .unwrap();
3575 client.set_window(&base, &["p1".into()]).unwrap();
3576 assert_eq!(client.window_state(&base).units, vec!["p1"]);
3577 }
3578
3579 #[test]
3580 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
3581 let mut client = client();
3582 client
3583 .conn
3584 .execute(
3585 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
3586 [],
3587 )
3588 .unwrap();
3589 assert_eq!(
3590 client.drain_pending_evictions().unwrap_err(),
3591 "sync.local_corrupt: invalid pending eviction scopes"
3592 );
3593 assert!(client.conn.is_autocommit());
3594 assert!(client.enqueue_startup_sync_if_needed().is_err());
3595 }
3596
3597 #[test]
3598 fn image_chunks_reject_null_primary_keys() {
3599 let mut client = client();
3600 let image = Connection::open_in_memory().unwrap();
3601 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3602 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
3603 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
3604 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3605 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
3606 let table = client.schema.table("tasks").unwrap().clone();
3607 assert!(client
3608 .apply_sqlite_image(
3609 &image,
3610 &table,
3611 true,
3612 &[("project_id".into(), vec!["p1".into()])],
3613 1025,
3614 7,
3615 "digest"
3616 )
3617 .is_err());
3618 assert!(client.conn.is_autocommit());
3619 assert_eq!(
3620 client
3621 .query("SELECT count(*) AS n FROM tasks", &[])
3622 .unwrap()[0]["n"],
3623 0
3624 );
3625 }
3626
3627 #[test]
3628 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
3629 for images in [false, true] {
3630 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
3631 "name":name,"primaryKey":"id","columns":[
3632 {"name":"id","type":"string","nullable":false},
3633 {"name":"project_id","type":"string","nullable":false},
3634 {"name":"title","type":"string","nullable":false}],
3635 "scopes":[{"pattern":"project:{project_id}"}],
3636 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
3637 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
3638 })))});
3639 let mut actual =
3640 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
3641 let mut reference =
3642 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
3643 let scopes = vec![("project_id".into(), vec!["p1".into()])];
3644 for client in [&mut actual, &mut reference] {
3645 client
3646 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
3647 .unwrap();
3648 client.subs[0].effective = Some(scopes.clone());
3649 for (table, id, title) in [
3650 ("tasks", "a", "original"),
3651 ("tasks", "b", "occupied"),
3652 ("tasks", "c", "deleted"),
3653 ("notes", "n", "untouched"),
3654 ] {
3655 client
3656 .write_base_row(
3657 table,
3658 &vec![
3659 Some(ColumnValue::String(id.into())),
3660 Some(ColumnValue::String("p1".into())),
3661 Some(ColumnValue::String(title.into())),
3662 ],
3663 1,
3664 )
3665 .unwrap();
3666 }
3667 client.rebuild_overlay_if_dirty();
3668 client
3669 .mutate(vec![
3670 Mutation::Upsert {
3671 table: "tasks".into(),
3672 values: Map::from_iter([
3673 ("id".into(), Value::from("a")),
3674 ("project_id".into(), Value::from("p1")),
3675 ("title".into(), Value::from("occupied")),
3676 ]),
3677 base_version: None,
3678 },
3679 Mutation::Delete {
3680 table: "tasks".into(),
3681 row_id: "c".into(),
3682 base_version: None,
3683 },
3684 Mutation::Upsert {
3685 table: "tasks".into(),
3686 values: Map::from_iter([
3687 ("id".into(), Value::from("local")),
3688 ("project_id".into(), Value::from("p1")),
3689 ("title".into(), Value::from("local value")),
3690 ]),
3691 base_version: None,
3692 },
3693 Mutation::Upsert {
3694 table: "notes".into(),
3695 values: Map::from_iter([
3696 ("id".into(), Value::from("n")),
3697 ("project_id".into(), Value::from("p1")),
3698 ("title".into(), Value::from("pending note")),
3699 ]),
3700 base_version: None,
3701 },
3702 ])
3703 .unwrap();
3704 }
3705 let pending = actual.pending_commit_ids();
3706 actual.overlay_rebuild_count.set(0);
3707 for (id, title) in [
3708 ("b", "available"),
3709 ("b", "occupied"),
3710 ("a", "server original"),
3711 ("d", "local value"),
3712 ("d", "released"),
3713 ("c", "redelivered"),
3714 ] {
3715 let table = actual.schema.table("tasks").unwrap().clone();
3716 let values = vec![
3717 Some(ColumnValue::String(id.into())),
3718 Some(ColumnValue::String("p1".into())),
3719 Some(ColumnValue::String(title.into())),
3720 ];
3721 if images {
3722 let image = Connection::open_in_memory().unwrap();
3723 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3724 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3725 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
3726 image
3727 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
3728 .unwrap();
3729 assert!(matches!(
3730 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
3731 Ok(1)
3732 ));
3733 } else {
3734 let segment = RowsSegment {
3735 table: "tasks".into(),
3736 schema_version: 1,
3737 columns: table.wire_columns.clone(),
3738 blocks: vec![vec![ssp2::segment::SegmentRow {
3739 server_version: 2,
3740 values: values.clone(),
3741 }]],
3742 };
3743 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
3744 }
3745 reference.write_base_row("tasks", &values, 2).unwrap();
3746 reference.rebuild_overlay();
3747 for table in ["tasks", "notes"] {
3748 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")] {
3749 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
3750 }
3751 }
3752 assert_eq!(actual.pending_commit_ids(), pending);
3753 }
3754 assert_eq!(
3755 actual.overlay_rebuild_count.get(),
3756 0,
3757 "each chunk must leave unrelated tables alone"
3758 );
3759 let before = actual
3760 .query("SELECT * FROM tasks ORDER BY id", &[])
3761 .unwrap();
3762 let fts_before = actual
3763 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
3764 .unwrap();
3765 let revision = actual.local_revision();
3766 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
3767 let table = actual.schema.table("tasks").unwrap().clone();
3768 let segment = RowsSegment {
3769 table: "tasks".into(),
3770 schema_version: 1,
3771 columns: table.wire_columns.clone(),
3772 blocks: vec![vec![ssp2::segment::SegmentRow {
3773 server_version: 3,
3774 values: vec![
3775 Some(ColumnValue::String("b".into())),
3776 Some(ColumnValue::String("p1".into())),
3777 Some(ColumnValue::String("fresh".into())),
3778 ],
3779 }]],
3780 };
3781 assert!(actual.apply_segment(0, &segment, false).is_err());
3782 assert!(actual.conn.is_autocommit());
3783 assert_eq!(
3784 actual
3785 .query("SELECT * FROM tasks ORDER BY id", &[])
3786 .unwrap(),
3787 before
3788 );
3789 assert_eq!(
3790 actual
3791 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
3792 .unwrap(),
3793 fts_before
3794 );
3795 assert_eq!(actual.local_revision(), revision);
3796 assert_eq!(actual.pending_commit_ids(), pending);
3797 }
3798 }
3799
3800 #[test]
3801 fn import_reconciliation_preserves_typed_pending_primary_keys() {
3802 for (kind, a, b) in [
3803 ("string", json!("a"), json!("b")),
3804 ("integer", json!(1), json!(2)),
3805 ("boolean", json!(true), json!(false)),
3806 ("json", json!("1"), json!("2")),
3807 ] {
3808 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}]}]});
3809 for images in [false, true] {
3810 let mut client =
3811 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
3812 let table = client.schema.table("tasks").unwrap().clone();
3813 client
3814 .subscribe("tasks".into(), "tasks".into(), vec![], None)
3815 .unwrap();
3816 for (id, title) in [(&a, "original"), (&b, "occupied")] {
3817 client
3818 .write_base_row(
3819 "tasks",
3820 &vec![
3821 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
3822 Some(ColumnValue::String(title.into())),
3823 ],
3824 1,
3825 )
3826 .unwrap();
3827 }
3828 client.rebuild_overlay_if_dirty();
3829 client
3830 .mutate(vec![Mutation::Upsert {
3831 table: "tasks".into(),
3832 values: Map::from_iter([
3833 ("id".into(), a.clone()),
3834 ("title".into(), json!("occupied")),
3835 ]),
3836 base_version: None,
3837 }])
3838 .unwrap();
3839 client.overlay_rebuild_count.set(0);
3840 for (title, expected) in [("available", "occupied"), ("occupied", "original")] {
3841 let values = vec![
3842 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
3843 Some(ColumnValue::String(title.into())),
3844 ];
3845 if images {
3846 let image = Connection::open_in_memory().unwrap();
3847 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3848 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3849 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
3850 image
3851 .execute(
3852 "INSERT INTO tasks VALUES (?1,?2,2)",
3853 rusqlite::params![RowParam::Cell(&values[0]), title],
3854 )
3855 .unwrap();
3856 assert!(
3857 matches!(
3858 client.apply_sqlite_image(
3859 &image,
3860 &table,
3861 false,
3862 &[],
3863 1,
3864 7,
3865 "digest"
3866 ),
3867 Ok(1)
3868 ),
3869 "{kind}, images={images}"
3870 );
3871 } else {
3872 let segment = RowsSegment {
3873 table: "tasks".into(),
3874 schema_version: 1,
3875 columns: table.wire_columns.clone(),
3876 blocks: vec![vec![ssp2::segment::SegmentRow {
3877 server_version: 2,
3878 values,
3879 }]],
3880 };
3881 assert!(
3882 matches!(client.apply_segment(0, &segment, false), Ok(1)),
3883 "{kind}"
3884 );
3885 }
3886 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
3887 let actual: String = client
3888 .conn
3889 .query_row(
3890 "SELECT title FROM tasks WHERE id=?1",
3891 [RowParam::Cell(&key)],
3892 |row| row.get(0),
3893 )
3894 .unwrap();
3895 assert_eq!(actual, expected, "{kind}, images={images}");
3896 }
3897 assert_eq!(client.overlay_rebuild_count.get(), 0);
3898 }
3899 }
3900 }
3901
3902 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
3903 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
3904 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
3905 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
3906 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
3907 })))});
3908 let mut client =
3909 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
3910 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();
3911 client.rebuild_overlay();
3912 client
3913 .mutate(vec![Mutation::Upsert {
3914 table: "tasks".into(),
3915 values: Map::from_iter([
3916 ("id".into(), json!("offline")),
3917 ("project_id".into(), json!("p1")),
3918 ("title".into(), json!("pending edit")),
3919 ]),
3920 base_version: None,
3921 }])
3922 .unwrap();
3923 let image = Connection::open_in_memory().unwrap();
3924 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3925 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
3926 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();
3927 image
3928 .execute(
3929 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
3930 [rows],
3931 )
3932 .unwrap();
3933 client.overlay_rebuild_count.set(0);
3934 let table = client.schema.table("tasks").unwrap().clone();
3935 (client, image, table)
3936 }
3937
3938 #[test]
3939 fn large_unique_import_copies_no_unrelated_tables() {
3940 let (mut client, image, table) = unique_import_fixture(10240);
3941 let pending = client.pending_commit_ids();
3942 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
3943 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
3944 assert!(matches!(
3945 client.apply_sqlite_image(
3946 &image,
3947 &table,
3948 true,
3949 &[("project_id".into(), vec!["p1".into()])],
3950 10240,
3951 7,
3952 "digest"
3953 ),
3954 Ok(10240)
3955 ));
3956 assert_eq!(client.overlay_rebuild_count.get(), 0);
3957 assert_eq!(client.pending_commit_ids(), pending);
3958 assert_eq!(
3959 client
3960 .query("SELECT count(*) AS n FROM tasks", &[])
3961 .unwrap()[0]["n"],
3962 10241
3963 );
3964 assert_eq!(
3965 client
3966 .query("SELECT count(*) AS n FROM tasks_fts", &[])
3967 .unwrap()[0]["n"],
3968 10241
3969 );
3970 assert_eq!(
3971 client
3972 .query("SELECT count(*) AS n FROM notes_fts", &[])
3973 .unwrap()[0]["n"],
3974 8192
3975 );
3976 }
3977
3978 #[test]
3979 #[ignore = "manual scaling benchmark; see bench/README.md"]
3980 fn benchmark_unique_image_import() {
3981 let mut samples = Vec::new();
3982 for rows in [10240, 51200, 102400] {
3983 for trial in 0..4 {
3984 let (mut client, image, table) = unique_import_fixture(rows);
3985 let start = std::time::Instant::now();
3986 assert!(
3987 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)
3988 );
3989 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
3990 assert_eq!(
3991 client
3992 .query("SELECT count(*) AS n FROM tasks", &[])
3993 .unwrap()[0]["n"],
3994 rows + 1
3995 );
3996 assert_eq!(
3997 client
3998 .query("SELECT count(*) AS n FROM tasks_fts", &[])
3999 .unwrap()[0]["n"],
4000 rows + 1
4001 );
4002 assert_eq!(
4003 client
4004 .query("SELECT count(*) AS n FROM notes_fts", &[])
4005 .unwrap()[0]["n"],
4006 8192
4007 );
4008 assert_eq!(client.pending_commit_ids().len(), 1);
4009 if trial > 0 {
4010 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4011 }
4012 }
4013 }
4014 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});
4015 let output = serde_json::to_string_pretty(&artifact).unwrap();
4016 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4017 use std::io::Write;
4018 let mut file = std::fs::OpenOptions::new()
4019 .write(true)
4020 .create_new(true)
4021 .open(path)
4022 .unwrap();
4023 file.write_all(output.as_bytes()).unwrap();
4024 } else {
4025 println!("{output}");
4026 }
4027 }
4028
4029 #[test]
4030 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4031 let schema = json!({
4032 "version": 1,
4033 "tables": [{
4034 "name": "catalogue_codes",
4035 "primaryKey": "id",
4036 "columns": [
4037 { "name": "id", "type": "string", "nullable": false },
4038 { "name": "release_id", "type": "string", "nullable": false },
4039 { "name": "code", "type": "string", "nullable": false },
4040 { "name": "title", "type": "string", "nullable": false }
4041 ],
4042 "scopes": [{ "pattern": "release:{release_id}" }],
4043 "ftsIndexes": [{
4044 "name": "catalogue_codes_fts",
4045 "columns": ["code", "title"],
4046 "tokenize": "unicode61 remove_diacritics 2"
4047 }]
4048 }]
4049 });
4050 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4051 .expect("FTS5 client");
4052 let insert_trigger: String = client
4053 .conn
4054 .query_row(
4055 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4056 [],
4057 |row| row.get(0),
4058 )
4059 .expect("insert trigger");
4060 let replace_guard: String = client
4061 .conn
4062 .query_row(
4063 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4064 [],
4065 |row| row.get(0),
4066 )
4067 .expect("replace guard");
4068 assert!(!insert_trigger.contains("DELETE FROM"));
4069 assert!(replace_guard.contains("BEFORE INSERT"));
4070 assert!(replace_guard.contains("WHEN EXISTS"));
4071 let search = |client: &SyncClient, query: &str| {
4072 client
4073 .query(
4074 "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",
4075 &[Value::from(query)],
4076 )
4077 .expect("FTS query")
4078 };
4079
4080 client
4081 .mutate(vec![Mutation::Upsert {
4082 table: "catalogue_codes".to_owned(),
4083 values: Map::from_iter([
4084 ("id".to_owned(), Value::from("c1")),
4085 ("release_id".to_owned(), Value::from("r1")),
4086 ("code".to_owned(), Value::from("A01")),
4087 ("title".to_owned(), Value::from("Cholera")),
4088 ]),
4089 base_version: None,
4090 }])
4091 .expect("insert code");
4092 assert_eq!(search(&client, "cholera").len(), 1);
4093 let prior = client
4094 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4095 .unwrap();
4096 client
4097 .conn
4098 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4099 .unwrap();
4100 client.create_synced_tables().unwrap();
4101 assert_eq!(
4102 client
4103 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4104 .unwrap(),
4105 prior
4106 );
4107 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();
4108 let rendered = serde_json::to_string(&plan).unwrap();
4109 assert!(rendered.contains("COVERING INDEX"));
4110 assert!(rendered.contains("INDEX 0:="));
4111 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4112 assert_eq!(search(&client, "cholera").len(), 1);
4113
4114 client
4115 .mutate(vec![Mutation::Upsert {
4116 table: "catalogue_codes".to_owned(),
4117 values: Map::from_iter([
4118 ("id".to_owned(), Value::from("c1")),
4119 ("release_id".to_owned(), Value::from("r1")),
4120 ("code".to_owned(), Value::from("A01")),
4121 ("title".to_owned(), Value::from("Enteric infection")),
4122 ]),
4123 base_version: None,
4124 }])
4125 .expect("update code");
4126 assert!(search(&client, "cholera").is_empty());
4127 assert_eq!(search(&client, "enteric").len(), 1);
4128
4129 client
4130 .mutate(vec![Mutation::Delete {
4131 table: "catalogue_codes".to_owned(),
4132 row_id: "c1".to_owned(),
4133 base_version: None,
4134 }])
4135 .expect("delete code");
4136 assert!(search(&client, "enteric").is_empty());
4137 }
4138
4139 #[test]
4140 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4141 let schema = json!({
4142 "version": 1,
4143 "tables": [{
4144 "name": "patient_notes",
4145 "primaryKey": "id",
4146 "columns": [
4147 { "name": "id", "type": "string", "nullable": false },
4148 { "name": "practice_id", "type": "string", "nullable": false },
4149 { "name": "encryption_key_id", "type": "string", "nullable": false },
4150 { "name": "title", "type": "string", "nullable": false }
4151 ],
4152 "scopes": [{ "pattern": "practice:{practice_id}" }],
4153 "ftsIndexes": [{
4154 "name": "patient_notes_fts",
4155 "columns": ["title"],
4156 "tokenize": "unicode61 remove_diacritics 2"
4157 }]
4158 }]
4159 });
4160 let mut client = SyncClient::new(
4161 "local-purge-test".to_owned(),
4162 &schema,
4163 ClientLimits::default(),
4164 )
4165 .expect("local purge client");
4166 client
4167 .conn
4168 .execute(
4169 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4170 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4171 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4172 [],
4173 )
4174 .expect("seed base rows");
4175 client.overlay_dirty.set(true);
4176 client.rebuild_overlay_if_dirty();
4177
4178 let note = |id: &str, key_id: &str, title: &str| {
4179 Map::from_iter([
4180 ("id".to_owned(), Value::from(id)),
4181 ("practice_id".to_owned(), Value::from("practice-1")),
4182 ("encryption_key_id".to_owned(), Value::from(key_id)),
4183 ("title".to_owned(), Value::from(title)),
4184 ])
4185 };
4186 let doomed = client
4187 .mutate(vec![
4188 Mutation::Upsert {
4189 table: "patient_notes".to_owned(),
4190 values: note("target", "key-revoked", "Target changed"),
4191 base_version: None,
4192 },
4193 Mutation::Upsert {
4194 table: "patient_notes".to_owned(),
4195 values: note("unrelated", "key-held", "Unrelated changed"),
4196 base_version: None,
4197 },
4198 ])
4199 .expect("doomed commit");
4200 let kept = client
4201 .mutate(vec![Mutation::Upsert {
4202 table: "patient_notes".to_owned(),
4203 values: note("kept", "key-held", "Kept optimistic"),
4204 base_version: None,
4205 }])
4206 .expect("kept commit");
4207 client.drain_change_batches();
4208
4209 let input = LocalDataPurgeInput {
4210 purge_id: "purge-001".to_owned(),
4211 targets: vec![LocalDataPurgeTarget {
4212 table: "patient_notes".to_owned(),
4213 selectors: BTreeMap::from([(
4214 "encryption_key_id".to_owned(),
4215 vec!["key-revoked".to_owned()],
4216 )]),
4217 }],
4218 };
4219 assert_eq!(
4220 client.purge_local_data(&input).expect("apply purge"),
4221 LocalDataPurgeResult {
4222 already_applied: false,
4223 purged_rows: 1,
4224 dropped_commits: 1,
4225 }
4226 );
4227 let rows = client
4228 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
4229 .expect("visible rows");
4230 assert_eq!(rows.len(), 2);
4231 assert_eq!(rows[0]["id"], "kept");
4232 assert_eq!(rows[1]["id"], "unrelated");
4233 assert_eq!(rows[1]["title"], "Unrelated original");
4234 let fts = client
4235 .query(
4236 "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'",
4237 &[],
4238 )
4239 .expect("fts query");
4240 assert!(fts.is_empty());
4241 assert_eq!(client.outbox.len(), 1);
4242 assert_eq!(client.outbox[0].client_commit_id, kept);
4243 let outcome = client
4244 .commit_outcome(&doomed)
4245 .expect("read doomed outcome")
4246 .expect("doomed outcome");
4247 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
4248 match &outcome.results[0] {
4249 CommitOperationOutcome::Error { rejection } => {
4250 assert_eq!(rejection.code, "client.local_data_purged");
4251 assert_eq!(rejection.client_commit_id, doomed);
4252 }
4253 other => panic!("expected local purge rejection, got {other:?}"),
4254 }
4255 assert_eq!(client.drain_change_batches().len(), 1);
4256 assert_eq!(
4257 client.purge_local_data(&input).expect("retry purge"),
4258 LocalDataPurgeResult {
4259 already_applied: true,
4260 purged_rows: 0,
4261 dropped_commits: 0,
4262 }
4263 );
4264 let conflicting = LocalDataPurgeInput {
4265 purge_id: input.purge_id.clone(),
4266 targets: vec![LocalDataPurgeTarget {
4267 table: "patient_notes".to_owned(),
4268 selectors: BTreeMap::from([(
4269 "encryption_key_id".to_owned(),
4270 vec!["key-held".to_owned()],
4271 )]),
4272 }],
4273 };
4274 assert!(client
4275 .purge_local_data(&conflicting)
4276 .expect_err("id collision must fail")
4277 .contains("already used with a different plan"));
4278 }
4279}
4280
4281impl SubState {
4282 fn name(self) -> &'static str {
4283 match self {
4284 SubState::Active => "active",
4285 SubState::Revoked => "revoked",
4286 SubState::Failed => "failed",
4287 }
4288 }
4289
4290 fn parse(value: &str) -> Self {
4291 match value {
4292 "revoked" => Self::Revoked,
4293 "failed" => Self::Failed,
4294 _ => Self::Active,
4295 }
4296 }
4297}
4298
4299#[derive(Debug, Clone)]
4300struct Subscription {
4301 id: String,
4302 table: String,
4303 requested: Vec<(String, Vec<String>)>,
4304 params: Option<String>,
4305 cursor: i64,
4306 bootstrap_state: Option<String>,
4308 state: SubState,
4309 reason_code: Option<String>,
4310 effective: Option<Vec<(String, Vec<String>)>>,
4313 synced_once: bool,
4314}
4315
4316#[derive(Debug, Clone)]
4317struct OutboxOp {
4318 upsert: bool,
4319 table: String,
4320 row_id: String,
4321 base_version: Option<i64>,
4322 values: Option<Map<String, Value>>,
4326}
4327
4328impl From<&OutboxOp> for CommitOperation {
4329 fn from(operation: &OutboxOp) -> Self {
4330 Self {
4331 table: operation.table.clone(),
4332 row_id: operation.row_id.clone(),
4333 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4334 base_version: operation.base_version,
4335 values: operation.values.clone(),
4336 }
4337 }
4338}
4339
4340#[derive(Debug, Clone)]
4341struct OutboxCommit {
4342 client_commit_id: String,
4343 ops: Vec<OutboxOp>,
4344}
4345
4346#[derive(Debug, Clone)]
4347struct CompiledLocalDataPurgeTarget {
4348 table: String,
4349 selectors: Vec<(String, Vec<String>)>,
4350}
4351
4352struct StoredCommitOutcomeRow {
4353 sequence: i64,
4354 client_commit_id: String,
4355 status: String,
4356 recorded_at_ms: i64,
4357 results_json: String,
4358 operations_json: Option<String>,
4359 resolution: String,
4360 resolved_at_ms: Option<i64>,
4361 replacement_client_commit_id: Option<String>,
4362}
4363
4364enum SectionError {
4367 FailClosed,
4368 Abort(String, String),
4369}
4370
4371struct RequestMeta {
4372 pushed_ids: Vec<String>,
4373 fresh: Vec<(String, bool)>,
4376 accept: u8,
4377 deferred_commits: usize,
4381}
4382
4383#[derive(Default)]
4384struct ChangeAccumulator {
4385 tables: BTreeMap<String, Option<BTreeSet<String>>>,
4387 windows: BTreeMap<(String, String), BTreeSet<String>>,
4388 status: bool,
4389 conflicts: bool,
4390 rejections: bool,
4391 outcomes: bool,
4392}
4393
4394impl ChangeAccumulator {
4395 fn table(&mut self, table: &str) {
4396 self.tables.insert(table.to_owned(), None);
4397 }
4398
4399 fn scope(&mut self, table: &str, key: String) {
4400 match self.tables.get_mut(table) {
4401 Some(None) => {}
4402 Some(Some(keys)) => {
4403 keys.insert(key);
4404 }
4405 None => {
4406 self.tables
4407 .insert(table.to_owned(), Some(BTreeSet::from([key])));
4408 }
4409 }
4410 }
4411
4412 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4413 self.windows
4414 .entry((base_key.to_owned(), table.to_owned()))
4415 .or_default()
4416 .insert(unit.to_owned());
4417 }
4418
4419 fn touched(&self) -> bool {
4420 !self.tables.is_empty()
4421 || !self.windows.is_empty()
4422 || self.status
4423 || self.conflicts
4424 || self.rejections
4425 || self.outcomes
4426 }
4427}
4428
4429const PUSH_OPS_PER_REQUEST: usize = 500;
4435const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4436const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4437const MAX_LOCAL_PURGE_VALUES: usize = 128;
4438const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4439pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4440
4441pub struct SyncClient {
4442 #[cfg(feature = "bench-internals")]
4443 benchmark_phases: Recorder,
4444 conn: Connection,
4445 schema: ClientSchema,
4446 client_id: String,
4447 limits: ClientLimits,
4448 subs: Vec<Subscription>,
4449 outbox: Vec<OutboxCommit>,
4450 conflicts: Vec<ConflictRecord>,
4451 rejections: Vec<RejectionRecord>,
4452 schema_floor: Option<SchemaFloor>,
4453 lease_state: Option<LeaseState>,
4455 stopped: bool,
4457 upgrading: bool,
4459 sync_needed: bool,
4461 realtime_connected: bool,
4462 presence: HashMap<String, HashMap<String, PresencePeer>>,
4464 now_ms: Option<i64>,
4467 encryption: crate::values::EncryptionConfig,
4471 security_preflight: bool,
4474 insert_sql: RefCell<HashMap<String, String>>,
4480 overlay_dirty: Cell<bool>,
4484 #[cfg(test)]
4487 overlay_rebuild_count: Cell<usize>,
4488 #[cfg(test)]
4491 outcome_prune_count: Cell<usize>,
4492 progress: ProgressObserver,
4494 change_queue: VecDeque<ClientChangeBatch>,
4495 sync_intent_queue: VecDeque<SyncIntent>,
4496 retry_delay_ms: u64,
4498 last_round: Option<DiagnosticLastRound>,
4499 last_change: Option<DiagnosticLastChange>,
4500}
4501
4502fn quote_ident(name: &str) -> String {
4503 format!("\"{}\"", name.replace('"', "\"\""))
4504}
4505
4506fn row_id_predicate(table: &TableSchema) -> String {
4508 let key = quote_ident(&table.primary_key);
4509 let text_match = format!("CAST({key} AS TEXT) = ?1");
4510 match table.columns[table.pk_index].ty {
4511 ColumnType::String | ColumnType::Json => {
4512 format!("{key} = ?1 AND {text_match}")
4513 }
4514 ColumnType::Integer | ColumnType::Boolean => {
4518 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
4519 }
4520 _ => text_match,
4523 }
4524}
4525
4526fn is_local_operation_code_like(value: &str) -> bool {
4527 let bytes = value.as_bytes();
4528 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
4529 && bytes
4530 .iter()
4531 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
4532}
4533
4534fn base_table(name: &str) -> String {
4535 quote_ident(&format!("_syncular_base_{name}"))
4536}
4537
4538type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
4543
4544fn window_base_key(base: &WindowBase) -> String {
4545 format!(
4546 "{}\0{}\0{}",
4547 base.table,
4548 base.variable,
4549 canonical_scope_json(&base.fixed_scopes)
4550 )
4551}
4552
4553fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
4555 let mut scopes = base.fixed_scopes.clone();
4556 scopes.retain(|(k, _)| k != &base.variable);
4557 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
4558 scopes
4559}
4560
4561fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
4565 let canonical = canonical_scope_json(&unit_scopes(base, unit));
4566 let digest = Sha256::digest(canonical.as_bytes());
4567 let hex = bytes_to_hex(&digest);
4568 format!("w:{}:{}", base.table, &hex[..16])
4569}
4570
4571fn visible_table(name: &str) -> String {
4572 quote_ident(name)
4573}
4574
4575const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
4576
4577fn is_synced_table_name(name: &str) -> bool {
4580 if name.starts_with("sqlite_") {
4581 return false;
4582 }
4583 if name.starts_with("_syncular_base_") {
4584 return true; }
4586 !name.starts_with("_syncular_")
4588}
4589
4590fn blob_id_for(bytes: &[u8]) -> String {
4592 let digest = Sha256::digest(bytes);
4593 format!("sha256:{}", bytes_to_hex(&digest))
4594}
4595
4596enum RowParam<'a> {
4600 Cell(&'a Option<ColumnValue>),
4601 Version(i64),
4602}
4603
4604impl rusqlite::ToSql for RowParam<'_> {
4605 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
4606 Ok(match self {
4607 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
4608 RowParam::Cell(cell) => match cell {
4609 None => ToSqlOutput::Owned(SqlValue::Null),
4610 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
4611 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
4612 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
4613 Some(ColumnValue::Boolean(b)) => {
4614 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
4615 }
4616 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
4617 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
4618 }
4619 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
4621 ToSqlOutput::Borrowed(ValueRef::Blob(b))
4622 }
4623 },
4624 })
4625 }
4626}
4627
4628fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
4630 match param.to_sql().map_err(|error| error.to_string())? {
4631 ToSqlOutput::Owned(value) => Ok(value),
4632 ToSqlOutput::Borrowed(value) => {
4633 SqlValue::try_from(value).map_err(|error| error.to_string())
4634 }
4635 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
4636 }
4637}
4638
4639fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
4647 use ssp2::segment::ColumnType;
4648 let mismatch = || {
4649 Err(format!(
4650 "image column {:?} holds a value of the wrong type",
4651 column.name
4652 ))
4653 };
4654 match value {
4655 ValueRef::Null => {
4656 if !column.nullable {
4657 return Err(format!(
4658 "image column {:?} is NULL but not nullable",
4659 column.name
4660 ));
4661 }
4662 Ok(ToSqlOutput::Owned(SqlValue::Null))
4663 }
4664 ValueRef::Integer(i) => match column.ty {
4665 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
4666 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
4667 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
4668 _ => mismatch(),
4669 },
4670 ValueRef::Real(_) => match column.ty {
4671 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
4672 _ => mismatch(),
4673 },
4674 ValueRef::Text(t) => {
4675 std::str::from_utf8(t)
4676 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
4677 match column.ty {
4678 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
4679 Ok(ToSqlOutput::Borrowed(value))
4680 }
4681 _ => mismatch(),
4682 }
4683 }
4684 ValueRef::Blob(_) => match column.ty {
4685 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
4687 _ => mismatch(),
4688 },
4689 }
4690}
4691
4692fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
4693 use rusqlite::types::ValueRef;
4694 match value {
4695 ValueRef::Null => Value::Null,
4696 ValueRef::Integer(i) => match column.ty {
4697 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
4698 ssp2::segment::ColumnType::Float => {
4699 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
4700 }
4701 _ => Value::from(i),
4702 },
4703 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
4704 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
4705 ValueRef::Blob(b) => {
4706 let mut map = Map::new();
4707 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
4708 Value::Object(map)
4709 }
4710 }
4711}
4712
4713fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
4717 Ok(match value {
4718 Value::Null => SqlValue::Null,
4719 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
4720 Value::Number(n) => {
4721 if let Some(i) = n.as_i64() {
4722 SqlValue::Integer(i)
4723 } else if let Some(f) = n.as_f64() {
4724 SqlValue::Real(f)
4725 } else {
4726 return Err(format!("query param number {n} is out of range"));
4727 }
4728 }
4729 Value::String(s) => SqlValue::Text(s.clone()),
4730 Value::Object(_) => {
4731 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
4732 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
4733 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
4734 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
4735 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
4736 })?)
4737 } else {
4738 return Err(
4739 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
4740 .to_owned(),
4741 );
4742 }
4743 }
4744 Value::Array(_) => return Err("query array params are not supported".to_owned()),
4745 })
4746}
4747
4748fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
4753 use rusqlite::types::ValueRef;
4754 match value {
4755 ValueRef::Null => Value::Null,
4756 ValueRef::Integer(i) => {
4757 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
4761 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
4762 Value::from(i)
4763 } else {
4764 let mut map = Map::new();
4765 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
4766 Value::Object(map)
4767 }
4768 }
4769 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
4770 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
4771 ValueRef::Blob(b) => {
4772 let mut map = Map::new();
4773 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
4774 Value::Object(map)
4775 }
4776 }
4777}
4778
4779fn query_connection(
4780 conn: &Connection,
4781 sql: &str,
4782 params: &[QueryValue],
4783) -> Result<Vec<QueryRow>, String> {
4784 crate::query_guard::assert_read_only_query(sql)?;
4785 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
4786 let bound: Vec<SqlValue> = params
4787 .iter()
4788 .map(json_param_to_sql)
4789 .collect::<Result<_, _>>()?;
4790 let mut stmt = conn.prepare(&lowered_sql).map_err(|e| e.to_string())?;
4791 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
4792 let bound_refs: Vec<&dyn rusqlite::ToSql> =
4793 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
4794 let mut sql_rows = stmt
4795 .query(bound_refs.as_slice())
4796 .map_err(|e| e.to_string())?;
4797 let mut out = Vec::new();
4798 while let Some(row) = sql_rows.next().map_err(|e| e.to_string())? {
4799 let mut record = Map::new();
4800 for (i, name) in column_names.iter().enumerate() {
4801 let value = row.get_ref(i).map_err(|e| e.to_string())?;
4802 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
4803 }
4804 out.push(record);
4805 }
4806 Ok(out)
4807}
4808
4809fn persisted_window_state(conn: &Connection, base: &WindowBase) -> Result<WindowState, String> {
4810 let mut stmt = conn
4811 .prepare(
4812 "SELECT windows.unit, subscriptions.state_json
4813 FROM _syncular_windows AS windows
4814 JOIN _syncular_subscriptions AS subscriptions
4815 ON subscriptions.id = windows.sub_id
4816 WHERE windows.base = ?1
4817 ORDER BY windows.unit ASC",
4818 )
4819 .map_err(|error| error.to_string())?;
4820 let rows = stmt
4821 .query_map(rusqlite::params![window_base_key(base)], |row| {
4822 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
4823 })
4824 .map_err(|error| error.to_string())?;
4825 let mut units = Vec::new();
4826 let mut pending = Vec::new();
4827 for row in rows {
4828 let (unit, raw) = row.map_err(|error| error.to_string())?;
4829 let state: Value = serde_json::from_str(&raw)
4830 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
4831 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
4832 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
4833 || state
4834 .get("bootstrapState")
4835 .is_some_and(|value| !value.is_null());
4836 if is_pending {
4837 pending.push(unit.clone());
4838 }
4839 units.push(unit);
4840 }
4841 Ok(WindowState { units, pending })
4842}
4843
4844fn snapshot_connection(
4845 conn: &Connection,
4846 sql: &str,
4847 params: &[Value],
4848 coverage: &[WindowCoverage],
4849) -> Result<QuerySnapshot, String> {
4850 conn.execute_batch("SAVEPOINT syncular_snapshot_read")
4851 .map_err(|error| error.to_string())?;
4852 let result = (|| {
4853 let revision = conn
4854 .query_row(
4855 "SELECT value FROM _syncular_meta WHERE key = ?1",
4856 rusqlite::params![LOCAL_REVISION_KEY],
4857 |row| row.get::<_, String>(0),
4858 )
4859 .ok()
4860 .and_then(|value| value.parse::<u64>().ok())
4861 .unwrap_or(0);
4862 let rows = query_connection(conn, sql, params)?;
4863 let mut pending = Vec::new();
4864 let mut missing = Vec::new();
4865 for requested in coverage {
4866 let base_key = window_base_key(&requested.base);
4867 let state = persisted_window_state(conn, &requested.base)?;
4868 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
4869 let reference = WindowUnitRef {
4870 base_key: base_key.clone(),
4871 unit: unit.clone(),
4872 };
4873 if !state.units.iter().any(|held| held == &unit) {
4874 missing.push(reference);
4875 } else if state.pending.iter().any(|held| held == &unit) {
4876 pending.push(reference);
4877 }
4878 }
4879 }
4880 Ok(QuerySnapshot {
4881 revision: revision.to_string(),
4882 rows,
4883 coverage: CoverageSnapshot {
4884 complete: pending.is_empty() && missing.is_empty(),
4885 pending,
4886 missing,
4887 },
4888 })
4889 })();
4890 match result {
4891 Ok(snapshot) => {
4892 conn.execute_batch("RELEASE syncular_snapshot_read")
4893 .map_err(|error| error.to_string())?;
4894 Ok(snapshot)
4895 }
4896 Err(error) => {
4897 let _ = conn.execute_batch(
4898 "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
4899 );
4900 Err(error)
4901 }
4902 }
4903}
4904
4905pub struct FileQuerySnapshotReader {
4910 path: String,
4911 conn: Option<Connection>,
4912}
4913
4914impl FileQuerySnapshotReader {
4915 #[must_use]
4916 pub fn new(path: impl Into<String>) -> Self {
4917 Self {
4918 path: path.into(),
4919 conn: None,
4920 }
4921 }
4922
4923 fn connection(&mut self) -> Result<&Connection, String> {
4924 if self.conn.is_none() {
4925 let conn = Connection::open_with_flags(
4926 &self.path,
4927 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
4928 )
4929 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
4930 conn.busy_timeout(std::time::Duration::from_millis(250))
4931 .map_err(|error| error.to_string())?;
4932 self.conn = Some(conn);
4933 }
4934 self.conn
4935 .as_ref()
4936 .ok_or_else(|| "read sidecar connection missing".to_owned())
4937 }
4938
4939 pub fn query_snapshot(
4940 &mut self,
4941 sql: &str,
4942 params: &[Value],
4943 coverage: &[WindowCoverage],
4944 ) -> Result<QuerySnapshot, String> {
4945 snapshot_connection(self.connection()?, sql, params, coverage)
4946 }
4947}
4948
4949impl SyncClient {
4950 pub fn new_with_identity(
4951 client_id: Option<String>,
4952 schema_json: &Value,
4953 limits: ClientLimits,
4954 ) -> Result<Self, String> {
4955 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
4956 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
4957 Self::with_connection(resolved, schema_json, limits, conn)
4958 }
4959
4960 pub fn new(
4961 client_id: String,
4962 schema_json: &Value,
4963 limits: ClientLimits,
4964 ) -> Result<Self, String> {
4965 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
4966 Self::with_connection(client_id, schema_json, limits, conn)
4967 }
4968
4969 pub fn open_path(
4975 client_id: String,
4976 schema_json: &Value,
4977 limits: ClientLimits,
4978 path: &str,
4979 ) -> Result<Self, String> {
4980 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
4981 Self::with_connection(client_id, schema_json, limits, conn)
4982 }
4983
4984 pub fn open_path_with_identity(
4985 client_id: Option<String>,
4986 schema_json: &Value,
4987 limits: ClientLimits,
4988 path: &str,
4989 ) -> Result<Self, String> {
4990 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
4991 conn.busy_timeout(std::time::Duration::from_millis(250))
4997 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
4998 conn.pragma_update(None, "journal_mode", "WAL")
4999 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5000 let persisted = conn
5001 .query_row(
5002 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5003 [],
5004 |row| row.get::<_, String>(0),
5005 )
5006 .ok();
5007 let resolved = persisted
5008 .clone()
5009 .or(client_id.clone())
5010 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5011 if let (Some(existing), Some(requested)) = (persisted, client_id) {
5012 if existing != requested {
5013 return Err(format!(
5014 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5015 ));
5016 }
5017 }
5018 Self::with_connection(resolved, schema_json, limits, conn)
5019 }
5020
5021 #[must_use]
5022 pub fn client_id(&self) -> &str {
5023 &self.client_id
5024 }
5025
5026 pub fn with_connection(
5033 client_id: String,
5034 schema_json: &Value,
5035 limits: ClientLimits,
5036 conn: Connection,
5037 ) -> Result<Self, String> {
5038 if limits.outcome_retention_max_entries == Some(0) {
5039 return Err(
5040 "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5041 );
5042 }
5043 let schema = parse_schema_json(schema_json)?;
5044 let mut client = SyncClient {
5045 #[cfg(feature = "bench-internals")]
5046 benchmark_phases: Recorder::default(),
5047 conn,
5048 schema,
5049 client_id,
5050 limits,
5051 subs: Vec::new(),
5052 outbox: Vec::new(),
5053 conflicts: Vec::new(),
5054 rejections: Vec::new(),
5055 schema_floor: None,
5056 lease_state: None,
5057 stopped: false,
5058 upgrading: false,
5059 sync_needed: false,
5060 realtime_connected: false,
5061 presence: HashMap::new(),
5062 now_ms: None,
5063 encryption: crate::values::EncryptionConfig::default(),
5064 security_preflight: false,
5065 insert_sql: RefCell::new(HashMap::new()),
5066 overlay_dirty: Cell::new(false),
5067 #[cfg(test)]
5068 overlay_rebuild_count: Cell::new(0),
5069 #[cfg(test)]
5070 outcome_prune_count: Cell::new(0),
5071 progress: ProgressObserver::default(),
5072 change_queue: VecDeque::new(),
5073 sync_intent_queue: VecDeque::new(),
5074 retry_delay_ms: 250,
5075 last_round: None,
5076 last_change: None,
5077 };
5078 client
5082 .conn
5083 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5084 client.create_bookkeeping_tables()?;
5088 match client.get_meta(CLIENT_ID_KEY) {
5089 Some(existing) if existing != client.client_id => {
5090 return Err(format!(
5091 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5092 client.client_id
5093 ));
5094 }
5095 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5096 _ => {}
5097 }
5098 client.restore_persisted_state()?;
5099 let marker = client
5100 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5101 .and_then(|value| value.parse::<i32>().ok());
5102 match marker {
5103 None => {
5104 client.create_synced_tables()?;
5105 client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5106 }
5107 Some(version) if version == client.schema.version => {
5108 client.create_synced_tables()?;
5109 }
5110 Some(_) => client.run_schema_reset()?,
5111 }
5112 client.clear_satisfied_persisted_schema_floor();
5113 client.prune_unknown_subscriptions()?;
5114 if marker == Some(client.schema.version) && !client.outbox.is_empty() {
5115 client.overlay_dirty.set(true);
5118 client.rebuild_overlay();
5119 }
5120 client.enqueue_startup_sync_if_needed()?;
5126 Ok(client)
5127 }
5128
5129 pub fn set_now_ms(&mut self, now_ms: i64) {
5132 self.now_ms = Some(now_ms);
5133 }
5134
5135 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
5139 self.encryption = encryption;
5140 }
5141
5142 #[must_use]
5143 pub fn security_lifecycle(&self) -> &'static str {
5144 if self.security_preflight {
5145 "preflight"
5146 } else {
5147 "active"
5148 }
5149 }
5150
5151 #[must_use]
5152 pub fn security_preflight(&self) -> bool {
5153 self.security_preflight
5154 }
5155
5156 pub fn begin_security_preflight(&mut self) {
5163 self.seal_security_on_teardown();
5164 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
5167 }
5168
5169 pub fn seal_security_on_teardown(&mut self) {
5179 self.security_preflight = true;
5180 self.encryption = crate::values::EncryptionConfig::default();
5181 self.sync_intent_queue.clear();
5182 }
5183
5184 pub fn activate_security(
5186 &mut self,
5187 encryption: crate::values::EncryptionConfig,
5188 ) -> Result<(), String> {
5189 if !self.security_preflight {
5190 return Err(
5191 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
5192 );
5193 }
5194 self.enqueue_startup_sync_if_needed()?;
5195 self.encryption = encryption;
5196 self.security_preflight = false;
5197 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
5198 Ok(())
5199 }
5200
5201 fn clock_now_ms(&self) -> i64 {
5202 self.now_ms.unwrap_or_else(|| {
5203 std::time::SystemTime::now()
5204 .duration_since(std::time::UNIX_EPOCH)
5205 .map(|d| d.as_millis() as i64)
5206 .unwrap_or(0)
5207 })
5208 }
5209
5210 fn create_bookkeeping_tables(&self) -> Result<(), String> {
5211 self.conn
5213 .execute_batch(
5214 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
5215 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5216 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
5217 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
5218 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5219 client_commit_id TEXT NOT NULL UNIQUE,
5220 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
5221 recorded_at_ms INTEGER NOT NULL,
5222 results_json TEXT NOT NULL,
5223 operations_json TEXT,
5224 resolution TEXT NOT NULL DEFAULT 'active'
5225 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
5226 resolved_at_ms INTEGER,
5227 replacement_client_commit_id TEXT);
5228 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
5229 ON _syncular_commit_outcomes(resolution, seq);
5230 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
5231 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
5232 CREATE TABLE IF NOT EXISTS _syncular_meta (
5233 key TEXT PRIMARY KEY, value TEXT NOT NULL);
5234 CREATE TABLE IF NOT EXISTS _syncular_windows (
5235 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
5236 PRIMARY KEY (base, unit));
5237 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
5238 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
5239 effective_scopes TEXT NOT NULL);
5240 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
5241 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
5242 PRIMARY KEY(commit_id, blob_id));
5243 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
5244 ON _syncular_blob_commit_refs(blob_id);",
5245 )
5246 .map_err(|e| e.to_string())?;
5247 let _ = self
5250 .conn
5251 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
5252 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
5253 self.set_meta(LOCAL_REVISION_KEY, "0");
5254 }
5255 if self.schema_has_blobs() {
5258 let exists = self
5259 .conn
5260 .query_row(
5261 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5262 [],
5263 |row| row.get::<_, bool>(0),
5264 )
5265 .map_err(|e| e.to_string())?;
5266 if exists {
5267 let mut statement = self
5268 .conn
5269 .prepare("PRAGMA table_info(_syncular_blobs)")
5270 .map_err(|e| e.to_string())?;
5271 let mut columns = statement
5272 .query_map([], |row| row.get::<_, String>(1))
5273 .map_err(|e| e.to_string())?
5274 .collect::<Result<Vec<_>, _>>()
5275 .map_err(|e| e.to_string())?;
5276 columns.sort();
5277 let mut expected = vec![
5278 "blob_id",
5279 "byte_length",
5280 "bytes",
5281 "created_at_ms",
5282 "media_type",
5283 ];
5284 expected.sort_unstable();
5285 if columns != expected {
5286 return Err(
5287 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5288 );
5289 }
5290 } else {
5291 self.conn
5292 .execute_batch(
5293 "CREATE TABLE _syncular_blobs(
5294 blob_id TEXT PRIMARY KEY,
5295 bytes BLOB NOT NULL,
5296 byte_length INTEGER NOT NULL,
5297 media_type TEXT,
5298 created_at_ms INTEGER NOT NULL);",
5299 )
5300 .map_err(|e| e.to_string())?;
5301 }
5302 self.conn
5303 .execute_batch(
5304 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5305 blob_id TEXT PRIMARY KEY,
5306 media_type TEXT,
5307 created_at_ms INTEGER NOT NULL);",
5308 )
5309 .map_err(|e| e.to_string())?;
5310 }
5311 Ok(())
5312 }
5313
5314 fn schema_has_blobs(&self) -> bool {
5316 self.schema
5317 .tables
5318 .iter()
5319 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5320 }
5321
5322 fn get_meta(&self, key: &str) -> Option<String> {
5325 self.conn
5326 .query_row(
5327 "SELECT value FROM _syncular_meta WHERE key = ?1",
5328 rusqlite::params![key],
5329 |row| row.get::<_, String>(0),
5330 )
5331 .ok()
5332 }
5333
5334 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5335 self.conn
5336 .query_row(
5337 "SELECT value FROM _syncular_meta WHERE key = ?1",
5338 rusqlite::params![key],
5339 |row| row.get::<_, String>(0),
5340 )
5341 .optional()
5342 .map_err(|_| {
5343 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5344 })
5345 }
5346
5347 fn set_meta(&self, key: &str, value: &str) {
5348 let _ = self.conn.execute(
5349 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
5350 rusqlite::params![key, value],
5351 );
5352 }
5353
5354 fn delete_meta(&self, key: &str) {
5355 let _ = self.conn.execute(
5356 "DELETE FROM _syncular_meta WHERE key = ?1",
5357 rusqlite::params![key],
5358 );
5359 }
5360
5361 fn restore_persisted_state(&mut self) -> Result<(), String> {
5362 self.subs = {
5363 let mut stmt = self
5364 .conn
5365 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5366 .map_err(|error| error.to_string())?;
5367 let rows = stmt
5368 .query_map([], |row| {
5369 Ok((
5370 row.get::<_, String>(0)?,
5371 row.get::<_, String>(1)?,
5372 row.get::<_, String>(2)?,
5373 ))
5374 })
5375 .map_err(|error| error.to_string())?;
5376 let mut subscriptions = Vec::new();
5377 for row in rows {
5378 let (id, table, raw) = row.map_err(|error| error.to_string())?;
5379 let state: Value = serde_json::from_str(&raw)
5380 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5381 let requested = json_to_scope_map(
5382 state.get("requested").unwrap_or(&Value::Object(Map::new())),
5383 )?;
5384 let effective = state
5385 .get("effectiveScopes")
5386 .filter(|value| !value.is_null())
5387 .map(json_to_scope_map)
5388 .transpose()?;
5389 subscriptions.push(Subscription {
5390 id,
5391 table,
5392 requested,
5393 params: state
5394 .get("params")
5395 .and_then(Value::as_str)
5396 .map(str::to_owned),
5397 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
5398 bootstrap_state: state
5399 .get("bootstrapState")
5400 .and_then(Value::as_str)
5401 .map(str::to_owned),
5402 state: SubState::parse(
5403 state
5404 .get("status")
5405 .and_then(Value::as_str)
5406 .unwrap_or("active"),
5407 ),
5408 reason_code: state
5409 .get("reasonCode")
5410 .and_then(Value::as_str)
5411 .map(str::to_owned),
5412 effective,
5413 synced_once: state
5414 .get("syncedOnce")
5415 .and_then(Value::as_bool)
5416 .unwrap_or(false),
5417 });
5418 }
5419 subscriptions
5420 };
5421
5422 self.outbox = {
5423 let mut stmt = self
5424 .conn
5425 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
5426 .map_err(|error| error.to_string())?;
5427 let rows = stmt
5428 .query_map([], |row| {
5429 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5430 })
5431 .map_err(|error| error.to_string())?;
5432 let mut commits = Vec::new();
5433 for row in rows {
5434 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
5435 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
5436 format!("invalid persisted outbox {client_commit_id:?}: {error}")
5437 })?;
5438 let mut ops = Vec::with_capacity(entries.len());
5439 for entry in entries {
5440 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
5441 ops.push(OutboxOp {
5442 upsert: op == "upsert",
5443 table: entry
5444 .get("table")
5445 .and_then(Value::as_str)
5446 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
5447 .to_owned(),
5448 row_id: entry
5449 .get("rowId")
5450 .and_then(Value::as_str)
5451 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
5452 .to_owned(),
5453 base_version: entry.get("baseVersion").and_then(Value::as_i64),
5454 values: entry.get("values").and_then(Value::as_object).cloned(),
5457 });
5458 }
5459 commits.push(OutboxCommit {
5460 client_commit_id,
5461 ops,
5462 });
5463 }
5464 commits
5465 };
5466
5467 self.prune_commit_outcomes()?;
5468 let active = self.commit_outcomes(CommitOutcomeQuery {
5469 active_only: true,
5470 ..CommitOutcomeQuery::default()
5471 })?;
5472 self.conflicts = active
5473 .iter()
5474 .flat_map(|outcome| outcome.results.iter())
5475 .filter_map(|result| match result {
5476 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
5477 _ => None,
5478 })
5479 .collect();
5480 self.rejections = active
5481 .iter()
5482 .flat_map(|outcome| outcome.results.iter())
5483 .filter_map(|result| match result {
5484 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
5485 _ => None,
5486 })
5487 .collect();
5488
5489 self.lease_state = self
5490 .get_meta(LEASE_STATE_KEY)
5491 .map(|raw| serde_json::from_str(&raw))
5492 .transpose()
5493 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
5494 self.schema_floor = self
5495 .get_meta(SCHEMA_FLOOR_KEY)
5496 .map(|raw| serde_json::from_str(&raw))
5497 .transpose()
5498 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
5499 self.stopped = self.schema_floor.is_some();
5500 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
5503 self.security_preflight = true;
5504 }
5505 Ok(())
5506 }
5507
5508 fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
5511 let valid_tables: BTreeSet<String> = self
5512 .schema
5513 .tables
5514 .iter()
5515 .map(|table| table.name.clone())
5516 .collect();
5517 let stale_ids: Vec<String> = self
5518 .subs
5519 .iter()
5520 .filter(|sub| !valid_tables.contains(&sub.table))
5521 .map(|sub| sub.id.clone())
5522 .collect();
5523 for id in &stale_ids {
5524 self.conn
5525 .execute(
5526 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
5527 rusqlite::params![id],
5528 )
5529 .map_err(|error| error.to_string())?;
5530 self.conn
5531 .execute(
5532 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
5533 rusqlite::params![id],
5534 )
5535 .map_err(|error| error.to_string())?;
5536 self.conn
5537 .execute(
5538 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
5539 rusqlite::params![id],
5540 )
5541 .map_err(|error| error.to_string())?;
5542 }
5543 self.subs.retain(|sub| valid_tables.contains(&sub.table));
5544 Ok(())
5545 }
5546
5547 #[must_use]
5548 pub fn local_revision(&self) -> u64 {
5549 self.get_meta(LOCAL_REVISION_KEY)
5550 .and_then(|value| value.parse().ok())
5551 .unwrap_or(0)
5552 }
5553
5554 #[must_use]
5555 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
5556 SyncStatusSnapshot {
5557 current_schema_version: self.schema.version,
5558 outbox: self.outbox.len(),
5559 upgrading: self.upgrading,
5560 lease_state: self.lease_state.clone(),
5561 schema_floor: self.schema_floor.clone(),
5562 sync_needed: self.sync_needed,
5563 }
5564 }
5565
5566 pub fn diagnostics_snapshot(
5567 &self,
5568 request: &ClientDiagnosticsRequest,
5569 ) -> Result<ClientDiagnosticsSnapshot, String> {
5570 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
5571 return Err(format!(
5572 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
5573 ));
5574 }
5575 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
5576 for sub in &self.subs {
5577 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
5578 let complete =
5579 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
5580 let state = match sub.state {
5581 SubState::Revoked => "revoked",
5582 SubState::Failed => "failed",
5583 SubState::Active if reset => "reset",
5584 SubState::Active if complete => "complete",
5585 SubState::Active => "bootstrapping",
5586 };
5587 subscriptions.insert(
5588 sub.id.clone(),
5589 DiagnosticSubscription {
5590 id: sub.id.clone(),
5591 table: sub.table.clone(),
5592 state: state.to_owned(),
5593 complete,
5594 cursor: Some(sub.cursor),
5595 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
5596 },
5597 );
5598 }
5599 for expected in &request.expected_subscriptions {
5600 if expected.id.is_empty() || expected.table.is_empty() {
5601 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
5602 }
5603 if subscriptions
5604 .get(&expected.id)
5605 .is_some_and(|registered| registered.table != expected.table)
5606 {
5607 subscriptions.insert(
5608 expected.id.clone(),
5609 DiagnosticSubscription {
5610 id: expected.id.clone(),
5611 table: expected.table.clone(),
5612 state: "failed".to_owned(),
5613 complete: false,
5614 cursor: None,
5615 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
5616 },
5617 );
5618 } else {
5619 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
5620 DiagnosticSubscription {
5621 id: expected.id.clone(),
5622 table: expected.table.clone(),
5623 state: "unregistered".to_owned(),
5624 complete: false,
5625 cursor: None,
5626 reason_code: None,
5627 }
5628 });
5629 }
5630 }
5631 let mut ordered_subscriptions = Vec::new();
5632 let mut included = BTreeSet::new();
5633 for expected in &request.expected_subscriptions {
5634 if included.insert(expected.id.clone()) {
5635 if let Some(subscription) = subscriptions.get(&expected.id) {
5636 ordered_subscriptions.push(subscription.clone());
5637 }
5638 }
5639 }
5640 for (id, subscription) in &subscriptions {
5641 if included.insert(id.clone()) {
5642 ordered_subscriptions.push(subscription.clone());
5643 }
5644 }
5645 let subscriptions_truncated =
5646 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
5647 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
5648 let captured_at_ms = self.clock_now_ms();
5649 let lease = if let Some(error_code) = self
5650 .lease_state
5651 .as_ref()
5652 .and_then(|state| state.error_code.clone())
5653 {
5654 ClientDiagnosticsLease {
5655 state: "stopped".to_owned(),
5656 expires_at_ms: self
5657 .lease_state
5658 .as_ref()
5659 .and_then(|state| state.expires_at_ms),
5660 error_code: Some(Self::diagnostic_code(&error_code)),
5661 }
5662 } else if let Some(expires_at_ms) = self
5663 .lease_state
5664 .as_ref()
5665 .and_then(|state| state.expires_at_ms)
5666 {
5667 ClientDiagnosticsLease {
5668 state: if expires_at_ms <= captured_at_ms {
5669 "expired".to_owned()
5670 } else {
5671 "active".to_owned()
5672 },
5673 expires_at_ms: Some(expires_at_ms),
5674 error_code: None,
5675 }
5676 } else {
5677 ClientDiagnosticsLease {
5678 state: "none".to_owned(),
5679 expires_at_ms: None,
5680 error_code: None,
5681 }
5682 };
5683 let connectivity = match self.last_round.as_ref() {
5684 Some(round) if round.status == "succeeded" => "online",
5685 Some(round)
5686 if round.status == "failed"
5687 && round
5688 .error_code
5689 .as_deref()
5690 .is_some_and(Self::retryable_transport_code) =>
5691 {
5692 "offline"
5693 }
5694 _ => "unknown",
5695 };
5696 Ok(ClientDiagnosticsSnapshot {
5697 version: CLIENT_DIAGNOSTICS_VERSION,
5698 captured_at_ms,
5699 host: ClientDiagnosticsHost {
5700 kind: "direct".to_owned(),
5701 role: "single".to_owned(),
5702 connectivity: connectivity.to_owned(),
5703 realtime: if self.realtime_connected {
5704 "connected".to_owned()
5705 } else {
5706 "disconnected".to_owned()
5707 },
5708 },
5709 security_lifecycle: self.security_lifecycle().to_owned(),
5710 schema: ClientDiagnosticsSchema {
5711 current_version: self.schema.version,
5712 upgrading: self.upgrading,
5713 required_version: self
5714 .schema_floor
5715 .as_ref()
5716 .and_then(|floor| floor.required_schema_version),
5717 latest_version: self
5718 .schema_floor
5719 .as_ref()
5720 .and_then(|floor| floor.latest_schema_version),
5721 },
5722 replica: ClientDiagnosticsReplica {
5723 local_revision: self.local_revision().to_string(),
5724 sync_needed: self.sync_needed,
5725 pending_outbox: self.outbox.len(),
5726 },
5727 lease,
5728 subscriptions: ordered_subscriptions,
5729 subscriptions_truncated,
5730 last_round: self.last_round.clone(),
5731 last_change: self.last_change.clone(),
5732 storage: self.diagnostics_storage(),
5733 })
5734 }
5735
5736 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
5737 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
5738 let page_count: i64 = self
5739 .conn
5740 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
5741 let page_size: i64 = self
5742 .conn
5743 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
5744 let outbox_bytes: i64 = self
5746 .conn
5747 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
5748 .query_row([], |row| row.get(0))?;
5749 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
5750 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
5751 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
5752 let blob_bytes = if self.schema_has_blobs() {
5753 self.conn
5754 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
5755 .query_row([], |row| row.get(0))?
5756 } else {
5757 0
5758 };
5759 let pressure = self
5760 .limits
5761 .blob_cache_max_bytes
5762 .is_some_and(|limit| blob_bytes > limit);
5763 Ok(ClientDiagnosticsStorage {
5764 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
5765 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
5766 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
5767 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
5768 retained_outcome_entries: Some(outcome_entries.max(0)),
5769 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
5770 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
5771 })
5772 };
5773 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
5774 status: "unreadable".to_owned(),
5775 database_bytes_approx: None,
5776 pending_outbox_bytes_approx: None,
5777 retained_outcome_bytes_approx: None,
5778 retained_outcome_entries: None,
5779 blob_cache_bytes_approx: None,
5780 pressure_reason_code: None,
5781 })
5782 }
5783
5784 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
5785 self.change_queue.drain(..).collect()
5786 }
5787
5788 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
5789 self.sync_intent_queue.drain(..).collect()
5790 }
5791
5792 fn schedule_background_retry(&mut self) {
5793 self.sync_intent_queue.push_back(SyncIntent::Background {
5794 delay_ms: self.retry_delay_ms,
5795 });
5796 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
5797 }
5798
5799 fn reset_background_retry(&mut self) {
5800 self.retry_delay_ms = 250;
5801 }
5802
5803 fn retryable_transport_code(code: &str) -> bool {
5804 code == "transport.failed"
5805 || code == "transport.unavailable"
5806 || code == "sync.transport_failed"
5807 }
5808
5809 fn diagnostic_code(code: &str) -> String {
5810 let valid = !code.is_empty()
5811 && code.len() <= 96
5812 && code.contains('.')
5813 && code
5814 .bytes()
5815 .next()
5816 .is_some_and(|byte| byte.is_ascii_lowercase())
5817 && code.bytes().all(|byte| {
5818 byte.is_ascii_lowercase()
5819 || byte.is_ascii_digit()
5820 || matches!(byte, b'.' | b'_' | b'-')
5821 });
5822 if valid {
5823 code.to_owned()
5824 } else {
5825 "client.unknown_failure".to_owned()
5826 }
5827 }
5828
5829 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
5830 if self.sync_needed != value {
5831 if self.begin_observation("syncular_status").is_ok() {
5832 self.sync_needed = value;
5833 let batch = ChangeAccumulator {
5834 status: true,
5835 ..ChangeAccumulator::default()
5836 };
5837 if self.finish_observation("syncular_status", batch).is_err() {
5838 self.rollback_observation("syncular_status");
5839 }
5840 } else {
5841 self.sync_needed = value;
5842 }
5843 }
5844 if value && interactive {
5845 self.sync_intent_queue.push_back(SyncIntent::Interactive);
5846 }
5847 }
5848
5849 fn begin_observation(&self, name: &str) -> Result<(), String> {
5850 self.conn
5851 .execute_batch(&format!("SAVEPOINT {name}"))
5852 .map_err(|error| error.to_string())
5853 }
5854
5855 fn rollback_observation(&self, name: &str) {
5856 let _ = self
5857 .conn
5858 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
5859 }
5860
5861 fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
5862 #[cfg(feature = "bench-internals")]
5863 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
5864 if !batch.touched() {
5865 self.conn
5866 .execute_batch(&format!("RELEASE {name}"))
5867 .map_err(|error| error.to_string())?;
5868 return Ok(());
5869 }
5870 let revision = self
5871 .local_revision()
5872 .checked_add(1)
5873 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
5874 self.conn
5875 .execute(
5876 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
5877 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
5878 )
5879 .map_err(|error| error.to_string())?;
5880 let status = batch.status.then(|| self.status_snapshot());
5881 let event = ClientChangeBatch {
5882 revision: revision.to_string(),
5883 tables: batch
5884 .tables
5885 .into_iter()
5886 .map(|(table, scope_keys)| TableChange {
5887 table,
5888 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
5889 })
5890 .collect(),
5891 windows: batch
5892 .windows
5893 .into_iter()
5894 .map(|((base_key, table), units)| WindowChange {
5895 base_key,
5896 table,
5897 units: units.into_iter().collect(),
5898 })
5899 .collect(),
5900 status,
5901 conflicts_changed: batch.conflicts,
5902 rejections_changed: batch.rejections,
5903 outcomes_changed: batch.outcomes,
5904 };
5905 self.conn
5906 .execute_batch(&format!("RELEASE {name}"))
5907 .map_err(|error| error.to_string())?;
5908 let mut diagnostic_tables = event
5909 .tables
5910 .iter()
5911 .map(|entry| entry.table.clone())
5912 .collect::<BTreeSet<_>>()
5913 .into_iter()
5914 .collect::<Vec<_>>();
5915 let mut diagnostic_windows = event
5916 .windows
5917 .iter()
5918 .map(|entry| entry.table.clone())
5919 .collect::<BTreeSet<_>>()
5920 .into_iter()
5921 .collect::<Vec<_>>();
5922 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
5923 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
5924 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
5925 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
5926 self.last_change = Some(DiagnosticLastChange {
5927 revision: event.revision.clone(),
5928 recorded_at_ms: self.clock_now_ms(),
5929 tables: diagnostic_tables,
5930 windows: diagnostic_windows,
5931 domains_truncated,
5932 status_changed: event.status.is_some(),
5933 conflicts_changed: event.conflicts_changed,
5934 rejections_changed: event.rejections_changed,
5935 outcomes_changed: event.outcomes_changed,
5936 });
5937 self.change_queue.push_back(event);
5938 Ok(())
5939 }
5940
5941 fn record_scope_map(
5942 &self,
5943 batch: &mut ChangeAccumulator,
5944 table_name: &str,
5945 scopes: &[(String, Vec<String>)],
5946 ) {
5947 let Some(table) = self.schema.table(table_name) else {
5948 return;
5949 };
5950 for (variable, values) in scopes {
5951 let Some(scope) = table
5952 .scope_variables
5953 .iter()
5954 .find(|scope| &scope.variable == variable)
5955 else {
5956 continue;
5957 };
5958 for value in values {
5959 batch.scope(table_name, format!("{}:{value}", scope.prefix));
5960 }
5961 }
5962 }
5963
5964 fn record_row_scopes(
5966 &self,
5967 batch: &mut ChangeAccumulator,
5968 table_name: &str,
5969 row_id: &str,
5970 base: bool,
5971 ) -> bool {
5972 let Some(table) = self.schema.table(table_name) else {
5973 return false;
5974 };
5975 if table.scope_variables.is_empty() {
5976 return false;
5977 }
5978 let columns = table
5979 .scope_variables
5980 .iter()
5981 .map(|scope| quote_ident(&scope.column))
5982 .collect::<Vec<_>>()
5983 .join(", ");
5984 let full_table = if base {
5985 base_table(table_name)
5986 } else {
5987 visible_table(table_name)
5988 };
5989 let sql = format!(
5990 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
5991 row_id_predicate(table)
5992 );
5993 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
5994 return false;
5995 };
5996 let values = stmt.query_row(rusqlite::params![row_id], |row| {
5997 let mut values = Vec::with_capacity(table.scope_variables.len());
5998 for index in 0..table.scope_variables.len() {
5999 values.push(row.get::<_, Option<String>>(index)?);
6000 }
6001 Ok(values)
6002 });
6003 let Ok(values) = values else {
6004 return false;
6005 };
6006 let mut recorded = false;
6007 for (scope, value) in table.scope_variables.iter().zip(values) {
6008 if let Some(value) = value {
6009 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6010 recorded = true;
6011 }
6012 }
6013 recorded
6014 }
6015
6016 fn record_commit_changes(
6017 &self,
6018 batch: &mut ChangeAccumulator,
6019 tables: &[String],
6020 changes: &[ssp2::model::Change],
6021 ) {
6022 #[cfg(feature = "bench-internals")]
6023 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
6024 for change in changes {
6025 let Some(table_name) = tables.get(change.table_index as usize) else {
6026 continue;
6027 };
6028 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
6029 if let Some(table) = self.schema.table(table_name) {
6030 for (variable, value) in &change.scopes {
6031 if let Some(scope) = table
6032 .scope_variables
6033 .iter()
6034 .find(|scope| &scope.variable == variable)
6035 {
6036 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6037 precise = true;
6038 }
6039 }
6040 }
6041 if !precise {
6042 batch.table(table_name);
6043 }
6044 }
6045 }
6046
6047 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
6048 if effective.is_empty() {
6049 return false;
6050 }
6051 let Some(table) = self.schema.table(table_name) else {
6052 return false;
6053 };
6054 let mut clauses = Vec::new();
6055 let mut params = Vec::new();
6056 for (variable, values) in effective {
6057 let Some(column) = table.scope_column(variable) else {
6058 return false;
6059 };
6060 if values.is_empty() {
6061 return false;
6062 }
6063 let placeholders = values
6064 .iter()
6065 .map(|value| {
6066 params.push(SqlValue::Text(value.clone()));
6067 "?"
6068 })
6069 .collect::<Vec<_>>()
6070 .join(", ");
6071 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
6072 }
6073 let sql = format!(
6074 "SELECT 1 FROM {} WHERE {} LIMIT 1",
6075 base_table(table_name),
6076 clauses.join(" AND ")
6077 );
6078 self.conn
6079 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
6080 .is_ok()
6081 }
6082
6083 pub fn upgrading(&self) -> bool {
6085 self.upgrading
6086 }
6087
6088 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
6094 let new_schema = parse_schema_json(schema_json)?;
6095 let marker: Option<i32> = self
6096 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6097 .and_then(|v| v.parse().ok());
6098 self.schema = new_schema;
6099 self.create_bookkeeping_tables()?;
6100 if marker != Some(self.schema.version) {
6101 self.run_schema_reset()?;
6102 }
6103 self.prune_unknown_subscriptions()?;
6104 self.enqueue_startup_sync_if_needed()?;
6108 Ok(())
6109 }
6110
6111 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
6112 let startup_work = !self.stopped
6113 && (!self.outbox.is_empty()
6114 || !self.load_pending_evictions()?.is_empty()
6115 || self.subs.iter().any(|sub| sub.state == SubState::Active));
6116 if startup_work {
6117 self.sync_needed = true;
6118 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6119 }
6120 Ok(())
6121 }
6122
6123 fn clear_satisfied_persisted_schema_floor(&mut self) {
6130 let satisfied = self
6131 .schema_floor
6132 .as_ref()
6133 .and_then(|floor| floor.required_schema_version)
6134 .is_some_and(|required| self.schema.version >= required);
6135 if !satisfied {
6136 return;
6137 }
6138 self.schema_floor = None;
6139 self.stopped = false;
6140 self.delete_meta(SCHEMA_FLOOR_KEY);
6141 }
6142
6143 fn run_schema_reset(&mut self) -> Result<(), String> {
6149 self.begin_observation("syncular_schema_reset")?;
6150 let mut batch = ChangeAccumulator::default();
6151 let result = self.run_schema_reset_observed(&mut batch, true);
6152 if let Err(error) = result {
6153 self.rollback_observation("syncular_schema_reset");
6154 return Err(error);
6155 }
6156 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
6157 self.rollback_observation("syncular_schema_reset");
6158 return Err(error);
6159 }
6160 Ok(())
6161 }
6162
6163 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
6164 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
6165 self.begin_observation("syncular_log_epoch_reset")?;
6166 let mut batch = ChangeAccumulator::default();
6167 let result = self.run_schema_reset_observed(&mut batch, false);
6168 if let Err(error) = result {
6169 self.rollback_observation("syncular_log_epoch_reset");
6170 return Err(error);
6171 }
6172 self.set_meta(LOG_EPOCH_KEY, log_epoch);
6173 self.sync_needed = true;
6174 batch.status = true;
6175 if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
6176 self.rollback_observation("syncular_log_epoch_reset");
6177 return Err(error);
6178 }
6179 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6180 Ok(resets)
6181 }
6182
6183 fn run_schema_reset_observed(
6184 &mut self,
6185 batch: &mut ChangeAccumulator,
6186 drop_incompatible: bool,
6187 ) -> Result<(), String> {
6188 self.upgrading = true;
6189 batch.status = true;
6190 for table in &self.schema.tables {
6191 batch.table(&table.name);
6192 }
6193 for (base_key, unit, table) in self.load_registered_window_units() {
6194 batch.window(&base_key, &table, &unit);
6195 }
6196 self.insert_sql.borrow_mut().clear();
6198 self.overlay_dirty.set(true);
6199 let virtual_tables: Vec<String> = {
6207 let mut stmt = self
6208 .conn
6209 .prepare(
6210 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
6211 )
6212 .map_err(|e| e.to_string())?;
6213 let rows = stmt
6214 .query_map([], |row| row.get::<_, String>(0))
6215 .map_err(|e| e.to_string())?;
6216 rows.filter_map(Result::ok)
6217 .filter(|name| is_synced_table_name(name))
6218 .collect()
6219 };
6220 for name in virtual_tables {
6221 self.conn
6222 .execute_batch(&format!(
6223 "DROP TABLE IF EXISTS {}",
6224 quote_ident(&format!("_syncular_fts_{name}"))
6225 ))
6226 .map_err(|e| e.to_string())?;
6227 self.conn
6228 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6229 .map_err(|e| e.to_string())?;
6230 }
6231 let existing: Vec<String> = {
6232 let mut stmt = self
6233 .conn
6234 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
6235 .map_err(|e| e.to_string())?;
6236 let rows = stmt
6237 .query_map([], |row| row.get::<_, String>(0))
6238 .map_err(|e| e.to_string())?;
6239 rows.filter_map(Result::ok)
6240 .filter(|name| is_synced_table_name(name))
6241 .collect()
6242 };
6243 for name in &existing {
6244 if let Some(table) = name.strip_prefix("_syncular_base_") {
6245 batch.table(table);
6246 } else if !name.starts_with("_syncular_") {
6247 batch.table(name);
6248 }
6249 }
6250 for name in existing {
6251 self.conn
6252 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6253 .map_err(|e| e.to_string())?;
6254 }
6255 self.create_synced_tables()?;
6257 for sub in &mut self.subs {
6259 sub.cursor = -1;
6260 sub.bootstrap_state = None;
6261 sub.effective = None;
6262 sub.state = SubState::Active;
6263 sub.reason_code = None;
6264 sub.synced_once = false;
6265 }
6266 let subs = self.subs.clone();
6267 for sub in &subs {
6268 self.persist_sub(sub)?;
6269 }
6270 self.stopped = false;
6272 self.schema_floor = None;
6273 self.delete_meta(SCHEMA_FLOOR_KEY);
6274 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
6276 if drop_incompatible && self.drop_incompatible_outbox()? {
6280 batch.rejections = true;
6281 batch.status = true;
6282 batch.outcomes = true;
6283 }
6284 self.rebuild_overlay();
6286 Ok(())
6287 }
6288
6289 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
6293 let schema = &self.schema;
6294 let incompatible = self
6295 .outbox
6296 .iter()
6297 .filter(|commit| {
6298 commit.ops.iter().any(|op| {
6299 if !op.upsert {
6300 return false;
6301 }
6302 match schema.table(&op.table) {
6303 None => true,
6304 Some(table) => op.values.as_ref().is_some_and(|values| {
6305 values
6306 .keys()
6307 .any(|key| !table.columns.iter().any(|c| &c.name == key))
6308 }),
6309 }
6310 })
6311 })
6312 .cloned()
6313 .collect::<Vec<_>>();
6314 if incompatible.is_empty() {
6315 return Ok(false);
6316 }
6317 let mut rejections = Vec::new();
6318 for commit in &incompatible {
6319 let results = commit
6320 .ops
6321 .iter()
6322 .enumerate()
6323 .map(|(op_index, operation)| {
6324 let rejection = RejectionRecord {
6325 client_commit_id: commit.client_commit_id.clone(),
6326 op_index: op_index as i32,
6327 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
6328 message: "the persisted commit cannot encode under the current schema"
6329 .to_owned(),
6330 retryable: false,
6331 details: None,
6332 operation: Some(CommitOperation::from(operation)),
6333 };
6334 rejections.push(rejection.clone());
6335 CommitOperationOutcome::Error { rejection }
6336 })
6337 .collect::<Vec<_>>();
6338 self.persist_commit_outcome(
6339 &commit.client_commit_id,
6340 CommitOutcomeStatus::Rejected,
6341 &results,
6342 Some(&commit.ops),
6343 )?;
6344 self.delete_outbox_persisted(&commit.client_commit_id)?;
6345 }
6346 self.prune_commit_outcomes()?;
6347 let incompatible_ids = incompatible
6348 .iter()
6349 .map(|commit| commit.client_commit_id.as_str())
6350 .collect::<BTreeSet<_>>();
6351 self.outbox
6352 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
6353 self.rejections.extend(rejections);
6354 Ok(true)
6355 }
6356
6357 fn create_synced_tables(&self) -> Result<(), String> {
6360 for table in &self.schema.tables {
6361 for (full, index_prefix) in [
6365 (base_table(&table.name), "_syncular_base_"),
6366 (visible_table(&table.name), ""),
6367 ] {
6368 let mut cols: Vec<String> =
6369 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
6370 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
6371 let sql = format!(
6372 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
6373 cols.join(", "),
6374 quote_ident(&table.primary_key)
6375 );
6376 self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
6377 for index in &table.indexes {
6381 let unique = if index.unique { "UNIQUE " } else { "" };
6382 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
6383 let cols_sql = index
6384 .columns
6385 .iter()
6386 .map(|c| quote_ident(c))
6387 .collect::<Vec<_>>()
6388 .join(", ");
6389 let index_sql = format!(
6390 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
6391 );
6392 self.conn
6393 .execute(&index_sql, [])
6394 .map_err(|e| e.to_string())?;
6395 }
6396 }
6397 }
6398 for table in &self.schema.tables {
6401 for index in &table.fts_indexes {
6402 self.create_fts_projection(table, index)?;
6403 }
6404 }
6405 Ok(())
6406 }
6407
6408 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
6409 let count: i64 = self
6410 .conn
6411 .query_row(
6412 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
6413 rusqlite::params![index.name],
6414 |row| row.get(0),
6415 )
6416 .map_err(|e| e.to_string())?;
6417 Ok(count > 0)
6418 }
6419
6420 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
6421 for suffix in ["ai", "ad", "au"] {
6422 self.conn
6423 .execute(
6424 &format!(
6425 "DROP TRIGGER IF EXISTS {}",
6426 quote_ident(&format!("{}_{suffix}", index.name))
6427 ),
6428 [],
6429 )
6430 .map_err(|e| e.to_string())?;
6431 }
6432 Ok(())
6433 }
6434
6435 fn create_fts_triggers(
6436 &self,
6437 table: &TableSchema,
6438 index: &FtsIndexSchema,
6439 ) -> Result<(), String> {
6440 let fts = quote_ident(&index.name);
6441 let source = visible_table(&table.name);
6442 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
6443 let pk = quote_ident(&table.primary_key);
6444 let projection_columns = std::iter::once(source_id.clone())
6445 .chain(index.columns.iter().map(|column| quote_ident(column)))
6446 .collect::<Vec<_>>()
6447 .join(", ");
6448 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
6449 .chain(
6450 index
6451 .columns
6452 .iter()
6453 .map(|column| format!("new.{}", quote_ident(column))),
6454 )
6455 .collect::<Vec<_>>()
6456 .join(", ");
6457 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6458 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)");
6459 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)");
6460 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})");
6461 let bi = quote_ident(&format!("{}_bi", index.name));
6462 let ai = quote_ident(&format!("{}_ai", index.name));
6463 let ad = quote_ident(&format!("{}_ad", index.name));
6464 let au = quote_ident(&format!("{}_au", index.name));
6465 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
6466 let sql = format!(
6467 "DROP TRIGGER IF EXISTS {bi};
6468 DROP TRIGGER IF EXISTS {ai};
6469 DROP TRIGGER IF EXISTS {ad};
6470 DROP TRIGGER IF EXISTS {au};
6471 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
6472 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
6473 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
6474 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
6475 );
6476 self.conn.execute_batch(&sql).map_err(|e| e.to_string())
6477 }
6478
6479 fn rebuild_fts_projection(
6480 &self,
6481 table: &TableSchema,
6482 index: &FtsIndexSchema,
6483 ) -> Result<(), String> {
6484 let fts = quote_ident(&index.name);
6485 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
6486 let pk = quote_ident(&table.primary_key);
6487 let indexed_columns = index
6488 .columns
6489 .iter()
6490 .map(|column| quote_ident(column))
6491 .collect::<Vec<_>>();
6492 let projection_columns = std::iter::once(source_id.clone())
6493 .chain(indexed_columns.iter().cloned())
6494 .collect::<Vec<_>>()
6495 .join(", ");
6496 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6497 let sql = format!(
6498 "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};",
6499 columns = indexed_columns.join(", "),
6500 source = visible_table(&table.name),
6501 );
6502 self.conn.execute_batch(&sql).map_err(|e| e.to_string())
6503 }
6504
6505 fn create_fts_projection(
6506 &self,
6507 table: &TableSchema,
6508 index: &FtsIndexSchema,
6509 ) -> Result<(), String> {
6510 self.begin_observation("syncular_fts_schema")?;
6511 let result = (|| {
6512 let existed = self.fts_projection_exists(index)?;
6513 let tokenizer = index.tokenize.replace('\'', "''");
6514 let columns = index
6515 .columns
6516 .iter()
6517 .map(|column| quote_ident(column))
6518 .collect::<Vec<_>>()
6519 .join(", ");
6520 let sql = format!(
6521 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
6522 fts = quote_ident(&index.name),
6523 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
6524 );
6525 self.conn.execute(&sql, []).map_err(|error| {
6526 format!(
6527 "cannot create local FTS5 projection {:?}: {error}",
6528 index.name
6529 )
6530 })?;
6531 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6532 let mapped: bool = self
6533 .conn
6534 .query_row(
6535 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
6536 [format!("_syncular_fts_{}", index.name)],
6537 |row| row.get(0),
6538 )
6539 .map_err(|e| e.to_string())?;
6540 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())?;
6541 if !mapped && existed {
6542 self.conn
6543 .execute_batch(&format!(
6544 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
6545 quote_ident(FTS_SOURCE_ID_COLUMN),
6546 quote_ident(&index.name)
6547 ))
6548 .map_err(|e| e.to_string())?;
6549 }
6550 self.create_fts_triggers(table, index)?;
6551 if !existed {
6552 self.rebuild_fts_projection(table, index)?;
6553 }
6554 Ok(())
6555 })();
6556 if let Err(error) = result {
6557 self.rollback_observation("syncular_fts_schema");
6558 return Err(error);
6559 }
6560 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
6561 self.rollback_observation("syncular_fts_schema");
6562 return Err(error.to_string());
6563 }
6564 Ok(())
6565 }
6566
6567 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
6570 let state = serde_json::json!({
6571 "requested": scope_map_to_json(&sub.requested),
6572 "params": sub.params,
6573 "cursor": sub.cursor,
6574 "bootstrapState": sub.bootstrap_state,
6575 "status": sub.state.name(),
6576 "reasonCode": sub.reason_code,
6577 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
6578 "syncedOnce": sub.synced_once,
6579 });
6580 self.conn.execute(
6581 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
6582 rusqlite::params![sub.id, sub.table, state.to_string()],
6583 ).map(|_| ()).map_err(|error| error.to_string())
6584 }
6585
6586 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
6587 let ops: Vec<Value> = commit
6588 .ops
6589 .iter()
6590 .map(|op| {
6591 serde_json::json!({
6592 "op": if op.upsert { "upsert" } else { "delete" },
6593 "table": op.table,
6594 "rowId": op.row_id,
6595 "baseVersion": op.base_version,
6596 "values": op.values.clone().map(Value::Object),
6597 })
6598 })
6599 .collect();
6600 self.conn
6601 .execute(
6602 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
6603 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
6604 )
6605 .map_err(|error| error.to_string())?;
6606 for operation in &commit.ops {
6607 let Some(values) = &operation.values else {
6608 continue;
6609 };
6610 let Some(table) = self.schema.table(&operation.table) else {
6611 continue;
6612 };
6613 for column in table
6614 .columns
6615 .iter()
6616 .filter(|column| column.ty == ColumnType::BlobRef)
6617 {
6618 let Some(Value::String(raw)) = values.get(&column.name) else {
6619 continue;
6620 };
6621 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
6622 continue;
6623 };
6624 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
6625 continue;
6626 };
6627 self.conn
6628 .execute(
6629 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
6630 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
6631 rusqlite::params![commit.client_commit_id, blob_id],
6632 )
6633 .map_err(|error| error.to_string())?;
6634 }
6635 }
6636 Ok(())
6637 }
6638
6639 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
6640 self.conn
6641 .execute(
6642 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
6643 rusqlite::params![client_commit_id],
6644 )
6645 .map_err(|error| error.to_string())?;
6646 self.conn
6647 .execute(
6648 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
6649 rusqlite::params![client_commit_id],
6650 )
6651 .map(|_| ())
6652 .map_err(|error| error.to_string())
6653 }
6654
6655 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
6656 match status {
6657 CommitOutcomeStatus::Applied => "applied",
6658 CommitOutcomeStatus::Cached => "cached",
6659 CommitOutcomeStatus::Conflict => "conflict",
6660 CommitOutcomeStatus::Rejected => "rejected",
6661 }
6662 }
6663
6664 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
6665 match resolution {
6666 CommitOutcomeResolution::Active => "active",
6667 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
6668 CommitOutcomeResolution::Superseded => "superseded",
6669 CommitOutcomeResolution::Dismissed => "dismissed",
6670 }
6671 }
6672
6673 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
6674 match value {
6675 "applied" => Ok(CommitOutcomeStatus::Applied),
6676 "cached" => Ok(CommitOutcomeStatus::Cached),
6677 "conflict" => Ok(CommitOutcomeStatus::Conflict),
6678 "rejected" => Ok(CommitOutcomeStatus::Rejected),
6679 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
6680 }
6681 }
6682
6683 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
6684 match value {
6685 "active" => Ok(CommitOutcomeResolution::Active),
6686 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
6687 "superseded" => Ok(CommitOutcomeResolution::Superseded),
6688 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
6689 _ => Err(format!(
6690 "invalid persisted commit outcome resolution {value:?}"
6691 )),
6692 }
6693 }
6694
6695 fn persist_commit_outcome(
6696 &self,
6697 client_commit_id: &str,
6698 status: CommitOutcomeStatus,
6699 results: &[CommitOperationOutcome],
6700 operations: Option<&[OutboxOp]>,
6701 ) -> Result<(), String> {
6702 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
6703 let operations_json = operations
6704 .map(|items| {
6705 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
6706 })
6707 .transpose()
6708 .map_err(|error| error.to_string())?;
6709 self.conn
6710 .execute(
6711 "INSERT INTO _syncular_commit_outcomes (
6712 client_commit_id, status, recorded_at_ms, results_json,
6713 operations_json, resolution
6714 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
6715 rusqlite::params![
6716 client_commit_id,
6717 Self::outcome_status_name(status),
6718 self.clock_now_ms(),
6719 results_json,
6720 operations_json
6721 ],
6722 )
6723 .map(|_| ())
6724 .map_err(|error| error.to_string())
6725 }
6726
6727 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
6728 let StoredCommitOutcomeRow {
6729 sequence,
6730 client_commit_id,
6731 status,
6732 recorded_at_ms,
6733 results_json,
6734 operations_json,
6735 resolution,
6736 resolved_at_ms,
6737 replacement_client_commit_id,
6738 } = row;
6739 Ok(CommitOutcome {
6740 sequence,
6741 client_commit_id,
6742 status: Self::parse_outcome_status(&status)?,
6743 recorded_at_ms,
6744 results: serde_json::from_str(&results_json)
6745 .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
6746 operations: operations_json
6747 .map(|value| {
6748 serde_json::from_str(&value).map_err(|error| {
6749 format!("invalid persisted commit outcome operations: {error}")
6750 })
6751 })
6752 .transpose()?,
6753 resolution: Self::parse_outcome_resolution(&resolution)?,
6754 resolved_at_ms,
6755 replacement_client_commit_id,
6756 })
6757 }
6758
6759 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
6760 let row = self
6761 .conn
6762 .query_row(
6763 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
6764 resolution, resolved_at_ms, replacement_client_commit_id
6765 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
6766 rusqlite::params![client_commit_id],
6767 |row| {
6768 Ok(StoredCommitOutcomeRow {
6769 sequence: row.get(0)?,
6770 client_commit_id: row.get(1)?,
6771 status: row.get(2)?,
6772 recorded_at_ms: row.get(3)?,
6773 results_json: row.get(4)?,
6774 operations_json: row.get(5)?,
6775 resolution: row.get(6)?,
6776 resolved_at_ms: row.get(7)?,
6777 replacement_client_commit_id: row.get(8)?,
6778 })
6779 },
6780 )
6781 .optional()
6782 .map_err(|error| error.to_string())?;
6783 row.map(Self::outcome_from_row).transpose()
6784 }
6785
6786 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
6787 if query.limit == Some(0) {
6788 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
6789 }
6790 let mut sql = String::from(
6791 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
6792 resolution, resolved_at_ms, replacement_client_commit_id
6793 FROM _syncular_commit_outcomes",
6794 );
6795 if query.active_only {
6796 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
6797 }
6798 sql.push_str(" ORDER BY seq DESC");
6799 if let Some(limit) = query.limit {
6800 sql.push_str(&format!(" LIMIT {limit}"));
6801 }
6802 let mut stmt = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
6803 let rows = stmt
6804 .query_map([], |row| {
6805 Ok(StoredCommitOutcomeRow {
6806 sequence: row.get(0)?,
6807 client_commit_id: row.get(1)?,
6808 status: row.get(2)?,
6809 recorded_at_ms: row.get(3)?,
6810 results_json: row.get(4)?,
6811 operations_json: row.get(5)?,
6812 resolution: row.get(6)?,
6813 resolved_at_ms: row.get(7)?,
6814 replacement_client_commit_id: row.get(8)?,
6815 })
6816 })
6817 .map_err(|error| error.to_string())?;
6818 let mut outcomes = Vec::new();
6819 for row in rows {
6820 outcomes.push(Self::outcome_from_row(
6821 row.map_err(|error| error.to_string())?,
6822 )?);
6823 }
6824 Ok(outcomes)
6825 }
6826
6827 fn prune_commit_outcomes(&self) -> Result<(), String> {
6828 #[cfg(test)]
6829 self.outcome_prune_count
6830 .set(self.outcome_prune_count.get() + 1);
6831 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
6832 let count = self
6833 .conn
6834 .query_row(
6835 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
6836 [],
6837 |row| row.get::<_, i64>(0),
6838 )
6839 .map_err(|error| error.to_string())? as usize;
6840 let excess = count.saturating_sub(max_entries);
6841 if excess == 0 {
6842 return Ok(());
6843 }
6844 let mut stmt = self
6845 .conn
6846 .prepare(
6847 "SELECT seq FROM _syncular_commit_outcomes
6848 WHERE status IN ('applied', 'cached') OR resolution != 'active'
6849 ORDER BY seq ASC LIMIT ?1",
6850 )
6851 .map_err(|error| error.to_string())?;
6852 let rows = stmt
6853 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
6854 .map_err(|error| error.to_string())?;
6855 let sequences = rows
6856 .collect::<Result<Vec<_>, _>>()
6857 .map_err(|error| error.to_string())?;
6858 drop(stmt);
6859 for sequence in sequences {
6860 self.conn
6861 .execute(
6862 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
6863 rusqlite::params![sequence],
6864 )
6865 .map_err(|error| error.to_string())?;
6866 }
6867 Ok(())
6868 }
6869
6870 pub fn subscribe(
6873 &mut self,
6874 id: String,
6875 table: String,
6876 scopes: Vec<(String, Vec<String>)>,
6877 params: Option<String>,
6878 ) -> Result<(), String> {
6879 if self.schema.table(&table).is_none() {
6880 return Err(format!("unknown table {table:?}"));
6881 }
6882 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
6883 let same_intent = existing.table == table
6884 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
6885 && existing.params == params;
6886 if same_intent {
6887 return Ok(());
6888 }
6889 return Err(
6890 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
6891 .to_owned(),
6892 );
6893 }
6894 let sub = Subscription {
6895 id: id.clone(),
6896 table,
6897 requested: scopes,
6898 params,
6899 cursor: -1,
6900 bootstrap_state: None,
6901 state: SubState::Active,
6902 reason_code: None,
6903 effective: None,
6904 synced_once: false,
6905 };
6906 self.persist_sub(&sub)?;
6907 self.subs.push(sub);
6908 Ok(())
6909 }
6910
6911 pub fn unsubscribe(&mut self, id: &str) {
6912 self.subs.retain(|s| s.id != id);
6913 let _ = self.conn.execute(
6914 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6915 rusqlite::params![id],
6916 );
6917 }
6918
6919 pub fn set_window(
6927 &mut self,
6928 base: &WindowBase,
6929 units: &[String],
6930 ) -> Result<CommandEffects, String> {
6931 let table = self
6932 .schema
6933 .table(&base.table)
6934 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
6935 if table.scope_column(&base.variable).is_none() {
6936 return Err(format!(
6937 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
6938 base.table, base.variable
6939 ));
6940 }
6941 let base_key = window_base_key(base);
6942 let wanted: std::collections::HashSet<&String> = units.iter().collect();
6943 let live = self.load_window_units(&base_key);
6944 self.begin_observation("syncular_window")?;
6945 let mut batch = ChangeAccumulator::default();
6946 let mut changed = false;
6947
6948 let prior_subs = self.subs.clone();
6949 let widened = (|| {
6950 for unit in units {
6952 if live.iter().any(|(u, _)| u == unit) {
6953 continue;
6954 }
6955 let sub_id = derive_sub_id(base, unit);
6956 self.delete_pending_evict(&sub_id)?;
6957 self.insert_window_unit(&base_key, unit, &sub_id)?;
6958 self.subscribe(
6959 sub_id,
6960 base.table.clone(),
6961 unit_scopes(base, unit),
6962 base.params.clone(),
6963 )?;
6964 batch.window(&base_key, &base.table, unit);
6965 changed = true;
6966 }
6967
6968 self.finish_observation("syncular_window", batch)
6969 })();
6970 if let Err(error) = widened {
6971 self.rollback_observation("syncular_window");
6972 self.subs = prior_subs;
6973 return Err(error);
6974 }
6975 for (unit, sub_id) in live {
6976 if wanted.contains(&unit) {
6977 continue;
6978 }
6979 self.rebuild_overlay_if_dirty();
6980 let prior_subs = self.subs.clone();
6981 self.begin_observation("syncular_window")?;
6982 let effective = self
6983 .subs
6984 .iter()
6985 .find(|sub| sub.id == sub_id)
6986 .and_then(|sub| sub.effective.clone())
6987 .unwrap_or_else(|| unit_scopes(base, &unit));
6988 let mut batch = ChangeAccumulator::default();
6989 self.record_scope_map(&mut batch, &base.table, &effective);
6990 batch.window(&base_key, &base.table, &unit);
6991 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
6992 let remaining = match result {
6993 Ok(remaining) => remaining,
6994 Err(error) => {
6995 self.rollback_observation("syncular_window");
6996 self.subs = prior_subs;
6997 return Err(error);
6998 }
6999 };
7000 if let Err(error) = self.finish_observation("syncular_window", batch) {
7001 self.rollback_observation("syncular_window");
7002 self.subs = prior_subs;
7003 return Err(error);
7004 }
7005 if remaining {
7006 self.drain_pending_evictions()?;
7007 }
7008 changed = true;
7009 }
7010 Ok(if changed {
7011 CommandEffects::interactive()
7012 } else {
7013 CommandEffects::none()
7014 })
7015 }
7016
7017 pub fn window_state(&self, base: &WindowBase) -> WindowState {
7022 let mut units = Vec::new();
7023 let mut pending = Vec::new();
7024 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
7025 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
7026 Some(sub) => {
7027 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
7028 }
7029 None => true,
7030 };
7031 if is_pending {
7032 pending.push(unit.clone());
7033 }
7034 units.push(unit);
7035 }
7036 WindowState { units, pending }
7037 }
7038
7039 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
7040 let mut stmt = match self
7041 .conn
7042 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
7043 {
7044 Ok(stmt) => stmt,
7045 Err(_) => return Vec::new(),
7046 };
7047 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
7048 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7049 });
7050 match rows {
7051 Ok(rows) => rows.filter_map(Result::ok).collect(),
7052 Err(_) => Vec::new(),
7053 }
7054 }
7055
7056 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
7057 let mut stmt = match self.conn.prepare(
7058 "SELECT windows.base, windows.unit, subscriptions.tbl
7059 FROM _syncular_windows AS windows
7060 JOIN _syncular_subscriptions AS subscriptions
7061 ON subscriptions.id = windows.sub_id
7062 ORDER BY windows.base, windows.unit",
7063 ) {
7064 Ok(stmt) => stmt,
7065 Err(_) => return Vec::new(),
7066 };
7067 let rows = stmt.query_map([], |row| {
7068 Ok((
7069 row.get::<_, String>(0)?,
7070 row.get::<_, String>(1)?,
7071 row.get::<_, String>(2)?,
7072 ))
7073 });
7074 match rows {
7075 Ok(rows) => rows.filter_map(Result::ok).collect(),
7076 Err(_) => Vec::new(),
7077 }
7078 }
7079
7080 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
7081 self.conn
7082 .query_row(
7083 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
7084 rusqlite::params![sub_id],
7085 |row| Ok((row.get(0)?, row.get(1)?)),
7086 )
7087 .ok()
7088 }
7089
7090 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
7091 self.conn
7092 .execute(
7093 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
7094 rusqlite::params![base_key, unit, sub_id],
7095 )
7096 .map(|_| ())
7097 .map_err(|error| error.to_string())
7098 }
7099
7100 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
7101 self.conn
7102 .execute(
7103 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
7104 rusqlite::params![base_key, unit],
7105 )
7106 .map(|_| ())
7107 .map_err(|error| error.to_string())
7108 }
7109
7110 fn evict_unit(
7117 &mut self,
7118 base_key: &str,
7119 base: &WindowBase,
7120 unit: &str,
7121 sub_id: &str,
7122 ) -> Result<bool, String> {
7123 let effective = self
7124 .subs
7125 .iter()
7126 .find(|s| s.id == sub_id)
7127 .and_then(|s| s.effective.clone())
7128 .unwrap_or_else(|| unit_scopes(base, unit));
7129 let pinned = self.pinned_row_ids(&base.table);
7130 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
7131 self.delete_window_unit(base_key, unit)?;
7132 self.conn
7133 .execute(
7134 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7135 [sub_id],
7136 )
7137 .map_err(|error| error.to_string())?;
7138 self.subs.retain(|sub| sub.id != sub_id);
7139 if deferred {
7140 self.save_pending_evict(sub_id, &base.table, &effective)?;
7141 } else {
7142 self.delete_pending_evict(sub_id)?;
7143 }
7144 Ok(remaining)
7145 }
7146
7147 fn evict_scope_rows(
7151 &self,
7152 table_name: &str,
7153 effective: &[(String, Vec<String>)],
7154 pinned: &std::collections::HashSet<String>,
7155 ) -> Result<(bool, bool), String> {
7156 if effective.is_empty() {
7157 return Ok((false, false));
7158 }
7159 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
7160 let mut clauses = Vec::new();
7161 let mut params: Vec<SqlValue> = Vec::new();
7162 for (variable, values) in effective {
7163 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
7164 if values.is_empty() {
7165 return Ok((false, false));
7166 }
7167 let holes = values
7168 .iter()
7169 .map(|v| {
7170 params.push(SqlValue::Text(v.clone()));
7171 "?"
7172 })
7173 .collect::<Vec<_>>()
7174 .join(", ");
7175 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
7176 }
7177 let scope = clauses.join(" AND ");
7178 let scope_params = params.len();
7179 let pk = quote_ident(&table.primary_key);
7180 if !pinned.is_empty() {
7181 let holes = pinned
7182 .iter()
7183 .map(|id| {
7184 params.push(SqlValue::Text(id.clone()));
7185 "?"
7186 })
7187 .collect::<Vec<_>>()
7188 .join(", ");
7189 clauses.push(format!("{pk} NOT IN ({holes})"));
7190 }
7191 let predicate = clauses.join(" AND ");
7192 let base = base_table(table_name);
7193 let visible = visible_table(table_name);
7194 let mut ids: Vec<SqlValue> = Vec::new();
7195 for source in [&base, &visible] {
7196 let mut stmt = self
7197 .conn
7198 .prepare_cached(&format!(
7199 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
7200 ))
7201 .map_err(|e| e.to_string())?;
7202 ids = stmt
7203 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
7204 .map_err(|e| e.to_string())?
7205 .collect::<Result<_, _>>()
7206 .map_err(|e| e.to_string())?;
7207 if !ids.is_empty() {
7208 break;
7209 }
7210 }
7211 if !ids.is_empty() {
7212 let holes = vec!["?"; ids.len()].join(", ");
7213 for source in [&base, &visible] {
7214 self.conn
7215 .execute(
7216 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
7217 rusqlite::params_from_iter(&ids),
7218 )
7219 .map_err(|e| e.to_string())?;
7220 }
7221 }
7222 let mut remaining = false;
7223 let mut deferred = false;
7224 for source in [&base, &visible] {
7225 remaining |= self
7226 .conn
7227 .query_row(
7228 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
7229 rusqlite::params_from_iter(¶ms),
7230 |row| row.get::<_, bool>(0),
7231 )
7232 .map_err(|e| e.to_string())?;
7233 deferred |= self
7234 .conn
7235 .query_row(
7236 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
7237 rusqlite::params_from_iter(¶ms[..scope_params]),
7238 |row| row.get::<_, bool>(0),
7239 )
7240 .map_err(|e| e.to_string())?;
7241 }
7242 Ok((remaining, deferred))
7243 }
7244
7245 fn drain_pending_evictions(&mut self) -> Result<(), String> {
7247 let pending = self.load_pending_evictions()?;
7248 if pending.is_empty() {
7249 return Ok(());
7250 }
7251 self.rebuild_overlay_if_dirty();
7252 for (sub_id, table_name, effective) in pending {
7253 if self.schema.table(&table_name).is_none() {
7254 self.delete_pending_evict(&sub_id)?;
7255 continue;
7256 }
7257 loop {
7258 std::thread::yield_now();
7259 self.begin_observation("syncular_evict")?;
7260 let pinned = self.pinned_row_ids(&table_name);
7261 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
7262 let (remaining, deferred) = match result {
7263 Ok(result) => result,
7264 Err(error) => {
7265 self.rollback_observation("syncular_evict");
7266 return Err(error);
7267 }
7268 };
7269 if !deferred {
7270 if let Err(error) = self.delete_pending_evict(&sub_id) {
7271 self.rollback_observation("syncular_evict");
7272 return Err(error);
7273 }
7274 }
7275 let mut batch = ChangeAccumulator::default();
7276 self.record_scope_map(&mut batch, &table_name, &effective);
7277 if let Err(error) = self.finish_observation("syncular_evict", batch) {
7278 self.rollback_observation("syncular_evict");
7279 return Err(error);
7280 }
7281 if !remaining {
7282 break;
7283 }
7284 }
7285 }
7286 Ok(())
7287 }
7288
7289 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
7292 let mut pinned = std::collections::HashSet::new();
7293 for commit in &self.outbox {
7294 for op in &commit.ops {
7295 if op.table == table {
7296 pinned.insert(op.row_id.clone());
7297 }
7298 }
7299 }
7300 pinned
7301 }
7302
7303 fn save_pending_evict(
7304 &self,
7305 sub_id: &str,
7306 table: &str,
7307 effective: &[(String, Vec<String>)],
7308 ) -> Result<(), String> {
7309 self.conn.execute(
7310 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
7311 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
7312 ).map(|_| ()).map_err(|error| error.to_string())
7313 }
7314
7315 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
7316 self.conn
7317 .execute(
7318 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
7319 rusqlite::params![sub_id],
7320 )
7321 .map(|_| ())
7322 .map_err(|error| error.to_string())
7323 }
7324
7325 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
7326 let mut stmt = self
7327 .conn
7328 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
7329 .map_err(|error| error.to_string())?;
7330 let rows = stmt
7331 .query_map([], |row| {
7332 Ok((
7333 row.get::<_, String>(0)?,
7334 row.get::<_, String>(1)?,
7335 row.get::<_, String>(2)?,
7336 ))
7337 })
7338 .map_err(|error| error.to_string())?;
7339 rows.map(|row| {
7340 let (sub_id, table, json) = row.map_err(|error| error.to_string())?;
7341 let value: Value = serde_json::from_str(&json)
7342 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7343 let effective = json_to_scope_map(&value)
7344 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7345 Ok((sub_id, table, effective))
7346 })
7347 .collect()
7348 }
7349
7350 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
7352 if mutations.is_empty() {
7353 return Err("a commit must contain at least one operation (§6.1)".to_owned());
7354 }
7355 let mut ops = Vec::with_capacity(mutations.len());
7356 for mutation in mutations {
7357 match mutation {
7358 Mutation::Upsert {
7359 table,
7360 values,
7361 base_version,
7362 } => {
7363 let schema_table = self
7364 .schema
7365 .table(&table)
7366 .ok_or_else(|| format!("unknown table {table:?}"))?;
7367 let values = normalize_values_casing(schema_table, values)?;
7371 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
7372 let values = full_row_values(schema_table, values)?;
7375 self.validate_author_encode(schema_table, &row_id, &values)?;
7381 ops.push(OutboxOp {
7382 upsert: true,
7383 table,
7384 row_id,
7385 base_version,
7386 values: Some(values),
7387 });
7388 }
7389 Mutation::Delete {
7390 table,
7391 row_id,
7392 base_version,
7393 } => {
7394 if self.schema.table(&table).is_none() {
7395 return Err(format!("unknown table {table:?}"));
7396 }
7397 ops.push(OutboxOp {
7398 upsert: false,
7399 table,
7400 row_id,
7401 base_version,
7402 values: None,
7403 });
7404 }
7405 }
7406 }
7407 self.record_outbox_commit(ops)
7408 }
7409
7410 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
7411 let commit = OutboxCommit {
7412 client_commit_id: uuid::Uuid::new_v4().to_string(),
7413 ops,
7414 };
7415 self.begin_observation("syncular_mutation")?;
7416 let mut batch = ChangeAccumulator::default();
7417 for op in &commit.ops {
7418 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
7419 if let Some(values) = &op.values {
7420 if let Some(table) = self.schema.table(&op.table) {
7421 for scope in &table.scope_variables {
7422 if let Some(Value::String(value)) = values.get(&scope.column) {
7423 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
7424 precise = true;
7425 }
7426 }
7427 }
7428 }
7429 if !precise {
7430 batch.table(&op.table);
7431 }
7432 }
7433 if let Err(error) = self.persist_outbox_insert(&commit) {
7434 self.rollback_observation("syncular_mutation");
7435 return Err(error);
7436 }
7437 let was_dirty = self.overlay_dirty.get();
7438 if !was_dirty {
7441 self.apply_outbox_ops(&commit.ops);
7442 }
7443 let id = commit.client_commit_id.clone();
7444 self.outbox.push(commit);
7445 if was_dirty {
7446 self.rebuild_overlay();
7447 }
7448 batch.status = true;
7449 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
7450 self.rollback_observation("syncular_mutation");
7451 self.outbox.pop();
7452 self.overlay_dirty.set(was_dirty);
7453 return Err(error);
7454 }
7455 Ok(id)
7456 }
7457
7458 pub fn patch(
7464 &mut self,
7465 table: &str,
7466 row_id: &str,
7467 partial: Map<String, Value>,
7468 base_version: Option<i64>,
7469 ) -> Result<String, String> {
7470 let schema_table = self
7471 .schema
7472 .table(table)
7473 .ok_or_else(|| format!("unknown table {table:?}"))?;
7474 let partial = normalize_values_casing(schema_table, partial)?;
7475 if let Some(pk) = partial.get(&schema_table.primary_key) {
7476 if pk.as_str() != Some(row_id) {
7477 return Err(format!(
7478 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
7479 ));
7480 }
7481 }
7482 for scope in &schema_table.scope_variables {
7492 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
7493 continue;
7494 }
7495 let Some(column) = schema_table
7496 .columns
7497 .iter()
7498 .find(|column| column.name == scope.column)
7499 else {
7500 continue;
7501 };
7502 let stored = self
7503 .conn
7504 .query_row(
7505 &format!(
7506 "SELECT {} FROM {} WHERE {}",
7507 quote_ident(&scope.column),
7508 visible_table(&schema_table.name),
7509 row_id_predicate(schema_table)
7510 ),
7511 rusqlite::params![row_id],
7512 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
7513 )
7514 .ok();
7515 let incoming = json_to_column_value(column, partial.get(&scope.column))
7520 .ok()
7521 .map(|value| column_value_to_json(&value));
7522 let equal = stored.as_ref().is_some_and(|stored| {
7526 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
7527 });
7528 if !equal {
7529 return Err(format!(
7530 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
7531 scope.column
7532 ));
7533 }
7534 }
7535 let mut values = partial;
7536 for scope in &schema_table.scope_variables {
7539 values.remove(&scope.column);
7540 }
7541 values.insert(
7542 schema_table.primary_key.clone(),
7543 Value::from(row_id.to_owned()),
7544 );
7545 self.validate_author_encode(schema_table, row_id, &values)?;
7551 self.record_outbox_commit(vec![OutboxOp {
7552 upsert: true,
7553 table: table.to_owned(),
7554 row_id: row_id.to_owned(),
7555 base_version,
7556 values: Some(values),
7557 }])
7558 }
7559
7560 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
7565 let selector = self.encryption.key_id_columns.get(&table.name)?;
7566 let index = table
7567 .columns
7568 .iter()
7569 .position(|column| &column.name == selector)?;
7570 let sql = format!(
7571 "SELECT {} FROM {} WHERE {}",
7572 quote_ident(selector),
7573 visible_table(&table.name),
7574 row_id_predicate(table)
7575 );
7576 let key: Option<String> = self
7577 .conn
7578 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
7579 .ok()?;
7580 let key = key.filter(|key| !key.is_empty())?;
7581 let mut fallback: Row = vec![None; table.columns.len()];
7582 fallback[index] = Some(ColumnValue::String(key));
7583 Some(fallback)
7584 }
7585
7586 fn validate_author_encode(
7591 &self,
7592 table: &TableSchema,
7593 row_id: &str,
7594 values: &Map<String, Value>,
7595 ) -> Result<(), String> {
7596 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
7597 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
7598 result => result.map(|_| ()),
7599 }
7600 }
7601
7602 fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
7608 if !self
7609 .schema
7610 .tables
7611 .iter()
7612 .any(|table| table.has_encrypted_columns())
7613 {
7614 return Ok(false);
7615 }
7616 let failures = self
7617 .outbox
7618 .iter()
7619 .filter_map(|commit| {
7620 commit
7621 .ops
7622 .iter()
7623 .find_map(|op| {
7624 let values = op.values.as_ref().filter(|_| op.upsert)?;
7625 let table = self.schema.table(&op.table)?;
7626 let fallback = self.stored_key_fallback(table, &op.row_id);
7627 match encode_sparse_row_json(
7628 table,
7629 &op.row_id,
7630 values,
7631 &self.encryption,
7632 fallback.as_ref(),
7633 ) {
7634 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
7635 _ => None,
7636 }
7637 })
7638 .map(|error| (commit.clone(), error))
7639 })
7640 .collect::<Vec<_>>();
7641 if failures.is_empty() {
7642 return Ok(false);
7643 }
7644 for (commit, message) in failures {
7645 let rejection = RejectionRecord {
7646 client_commit_id: commit.client_commit_id.clone(),
7647 op_index: 0,
7648 code: ENCRYPT_FAILED_CODE.to_owned(),
7649 message,
7650 retryable: false,
7651 details: None,
7652 operation: commit.ops.first().map(CommitOperation::from),
7653 };
7654 self.persist_commit_outcome(
7655 &commit.client_commit_id,
7656 CommitOutcomeStatus::Rejected,
7657 &[CommitOperationOutcome::Error {
7658 rejection: rejection.clone(),
7659 }],
7660 Some(&commit.ops),
7661 )?;
7662 self.delete_outbox_persisted(&commit.client_commit_id)?;
7663 self.outbox
7664 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
7665 self.rejections.push(rejection);
7666 }
7667 self.prune_commit_outcomes()?;
7668 self.overlay_dirty.set(true);
7669 self.rebuild_overlay();
7670 Ok(true)
7671 }
7672
7673 pub fn pending_commit_ids(&self) -> Vec<String> {
7674 self.outbox
7675 .iter()
7676 .map(|c| c.client_commit_id.clone())
7677 .collect()
7678 }
7679
7680 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
7684 let mut payloads = Vec::new();
7685 for commit in &self.outbox {
7686 for op in &commit.ops {
7687 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
7688 continue;
7689 };
7690 let Some(table) = self.schema.table(&op.table) else {
7691 continue;
7692 };
7693 let fallback = self.stored_key_fallback(table, &op.row_id);
7694 if let Ok(payload) = encode_sparse_row_json(
7695 table,
7696 &op.row_id,
7697 values,
7698 &self.encryption,
7699 fallback.as_ref(),
7700 ) {
7701 payloads.push(payload);
7702 }
7703 }
7704 }
7705 payloads
7706 }
7707
7708 pub fn conflicts(&self) -> &[ConflictRecord] {
7709 &self.conflicts
7710 }
7711
7712 pub fn rejections(&self) -> &[RejectionRecord] {
7713 &self.rejections
7714 }
7715
7716 pub fn resolve_commit_outcome(
7717 &mut self,
7718 input: ResolveCommitOutcomeInput,
7719 ) -> Result<CommitOutcome, String> {
7720 let current = self
7721 .commit_outcome(&input.client_commit_id)?
7722 .ok_or_else(|| {
7723 format!(
7724 "sync.outcome_not_found: no durable outcome exists for {:?}",
7725 input.client_commit_id
7726 )
7727 })?;
7728 if current.resolution != CommitOutcomeResolution::Active {
7729 return Ok(current);
7730 }
7731 if input.resolution == CommitOutcomeResolution::Active {
7732 return Err("sync.invalid_request: resolution must leave active state".to_owned());
7733 }
7734 match input.resolution {
7735 CommitOutcomeResolution::Superseded => {
7736 let replacement = input
7737 .replacement_client_commit_id
7738 .as_deref()
7739 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
7740 .ok_or_else(|| {
7741 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
7742 .to_owned()
7743 })?;
7744 let _ = replacement;
7745 }
7746 _ if input.replacement_client_commit_id.is_some() => {
7747 return Err(
7748 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
7749 .to_owned(),
7750 );
7751 }
7752 _ => {}
7753 }
7754 let allowed = match current.status {
7755 CommitOutcomeStatus::Conflict => matches!(
7756 input.resolution,
7757 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
7758 ),
7759 CommitOutcomeStatus::Rejected => {
7760 input.resolution == CommitOutcomeResolution::Superseded
7761 }
7762 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
7763 input.resolution == CommitOutcomeResolution::Dismissed
7764 }
7765 };
7766 if !allowed {
7767 return Err(format!(
7768 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
7769 input.resolution, current.status
7770 ));
7771 }
7772
7773 self.begin_observation("syncular_outcome_resolution")?;
7774 let result = (|| {
7775 self.conn
7776 .execute(
7777 "UPDATE _syncular_commit_outcomes
7778 SET resolution = ?1, resolved_at_ms = ?2,
7779 replacement_client_commit_id = ?3
7780 WHERE client_commit_id = ?4 AND resolution = 'active'",
7781 rusqlite::params![
7782 Self::outcome_resolution_name(input.resolution),
7783 self.clock_now_ms(),
7784 input.replacement_client_commit_id,
7785 input.client_commit_id
7786 ],
7787 )
7788 .map_err(|error| error.to_string())?;
7789 let resolved = self
7790 .commit_outcome(¤t.client_commit_id)?
7791 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
7792 self.prune_commit_outcomes()?;
7793 Ok(resolved)
7794 })();
7795 let resolved = match result {
7796 Ok(outcome) => outcome,
7797 Err(error) => {
7798 self.rollback_observation("syncular_outcome_resolution");
7799 return Err(error);
7800 }
7801 };
7802 self.conflicts
7803 .retain(|record| record.client_commit_id != current.client_commit_id);
7804 self.rejections
7805 .retain(|record| record.client_commit_id != current.client_commit_id);
7806 let batch = ChangeAccumulator {
7807 conflicts: current.status == CommitOutcomeStatus::Conflict,
7808 rejections: current.status == CommitOutcomeStatus::Rejected,
7809 outcomes: true,
7810 ..ChangeAccumulator::default()
7811 };
7812 if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
7813 self.rollback_observation("syncular_outcome_resolution");
7814 return Err(error);
7815 }
7816 Ok(resolved)
7817 }
7818
7819 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
7820 self.schema_floor.as_ref()
7821 }
7822
7823 pub fn lease_state(&self) -> Option<&LeaseState> {
7825 self.lease_state.as_ref()
7826 }
7827
7828 fn record_lease_error(&mut self, code: &str) {
7831 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
7832 return;
7833 }
7834 let mut next = self.lease_state.clone().unwrap_or_default();
7835 next.error_code = Some(code.to_owned());
7836 self.set_lease_state(Some(next));
7837 }
7838
7839 fn set_lease_state(&mut self, next: Option<LeaseState>) {
7840 if self.lease_state == next {
7841 return;
7842 }
7843 if self.begin_observation("syncular_lease").is_err() {
7844 return;
7845 }
7846 self.lease_state = next;
7847 if let Some(lease) = &self.lease_state {
7848 if let Ok(json) = serde_json::to_string(lease) {
7849 self.set_meta(LEASE_STATE_KEY, &json);
7850 }
7851 } else {
7852 self.delete_meta(LEASE_STATE_KEY);
7853 }
7854 let batch = ChangeAccumulator {
7855 status: true,
7856 ..ChangeAccumulator::default()
7857 };
7858 if self.finish_observation("syncular_lease", batch).is_err() {
7859 self.rollback_observation("syncular_lease");
7860 }
7861 }
7862
7863 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
7864 if self.schema_floor == next {
7865 return;
7866 }
7867 if self.begin_observation("syncular_schema_floor").is_err() {
7868 return;
7869 }
7870 self.schema_floor = next;
7871 self.stopped = self.schema_floor.is_some();
7872 if let Some(floor) = &self.schema_floor {
7873 if let Ok(json) = serde_json::to_string(floor) {
7874 self.set_meta(SCHEMA_FLOOR_KEY, &json);
7875 }
7876 } else {
7877 self.delete_meta(SCHEMA_FLOOR_KEY);
7878 }
7879 let batch = ChangeAccumulator {
7880 status: true,
7881 ..ChangeAccumulator::default()
7882 };
7883 if self
7884 .finish_observation("syncular_schema_floor", batch)
7885 .is_err()
7886 {
7887 self.rollback_observation("syncular_schema_floor");
7888 }
7889 }
7890
7891 fn set_upgrading(&mut self, value: bool) {
7892 if self.upgrading == value {
7893 return;
7894 }
7895 if self.begin_observation("syncular_upgrading").is_err() {
7896 return;
7897 }
7898 self.upgrading = value;
7899 let batch = ChangeAccumulator {
7900 status: true,
7901 ..ChangeAccumulator::default()
7902 };
7903 if self
7904 .finish_observation("syncular_upgrading", batch)
7905 .is_err()
7906 {
7907 self.rollback_observation("syncular_upgrading");
7908 }
7909 }
7910
7911 pub fn sync_needed(&self) -> bool {
7912 self.sync_needed
7913 }
7914
7915 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
7916 let sub = self.subs.iter().find(|s| s.id == id)?;
7917 Some(SubscriptionStateView {
7918 id: sub.id.clone(),
7919 table: sub.table.clone(),
7920 status: sub.state.name().to_owned(),
7921 cursor: sub.cursor,
7922 has_resume_token: sub.bootstrap_state.is_some(),
7923 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7924 reason_code: sub.reason_code.clone(),
7925 })
7926 }
7927
7928 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
7929 let schema_table = self
7930 .schema
7931 .table(table)
7932 .ok_or_else(|| format!("unknown table {table:?}"))?;
7933 let sql = format!(
7934 "SELECT * FROM {} ORDER BY {} ASC",
7935 visible_table(table),
7936 quote_ident(&schema_table.primary_key)
7937 );
7938 let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
7939 let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
7940 let mut out = Vec::new();
7941 while let Some(row) = rows.next().map_err(|e| e.to_string())? {
7942 let mut values = Map::new();
7943 for (i, column) in schema_table.columns.iter().enumerate() {
7944 let value = row.get_ref(i).map_err(|e| e.to_string())?;
7945 values.insert(column.name.clone(), sql_ref_to_json(column, value));
7946 }
7947 let version: i64 = row
7948 .get(schema_table.columns.len())
7949 .map_err(|e| e.to_string())?;
7950 let row_id = match values.get(&schema_table.primary_key) {
7951 Some(Value::String(s)) => s.clone(),
7952 Some(Value::Number(n)) => n.to_string(),
7953 Some(Value::Bool(b)) => b.to_string(),
7954 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
7955 };
7956 out.push(RowState {
7957 row_id,
7958 version,
7959 values,
7960 });
7961 }
7962 Ok(out)
7963 }
7964
7965 #[cfg(feature = "crdt-yjs")]
7981 fn crdt_column_bytes(
7982 &self,
7983 table: &str,
7984 row_id: &str,
7985 column: &str,
7986 ) -> Result<Option<Vec<u8>>, String> {
7987 let schema_table = self
7988 .schema
7989 .table(table)
7990 .ok_or_else(|| format!("unknown table {table:?}"))?;
7991 let col = schema_table
7992 .columns
7993 .iter()
7994 .find(|c| c.name == column)
7995 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
7996 if col.ty != ColumnType::Crdt {
7997 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
7998 }
7999 let sql = format!(
8000 "SELECT {} FROM {} WHERE {}",
8001 quote_ident(column),
8002 visible_table(table),
8003 row_id_predicate(schema_table)
8004 );
8005 let bytes: Option<Vec<u8>> = self
8006 .conn
8007 .prepare_cached(&sql)
8008 .map_err(|error| error.to_string())?
8009 .query_row(rusqlite::params![row_id], |row| {
8010 row.get::<_, Option<Vec<u8>>>(0)
8011 })
8012 .map_err(|e| match e {
8013 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
8014 other => other.to_string(),
8015 })?;
8016 Ok(bytes)
8017 }
8018
8019 #[cfg(feature = "crdt-yjs")]
8023 pub fn crdt_text(
8024 &self,
8025 table: &str,
8026 row_id: &str,
8027 column: &str,
8028 name: &str,
8029 ) -> Result<String, String> {
8030 let bytes = self
8031 .crdt_column_bytes(table, row_id, column)?
8032 .unwrap_or_default();
8033 crate::crdt::text(&bytes, name)
8034 }
8035
8036 #[cfg(feature = "crdt-yjs")]
8040 pub fn crdt_insert_text(
8041 &mut self,
8042 table: &str,
8043 row_id: &str,
8044 column: &str,
8045 name: &str,
8046 index: u32,
8047 value: &str,
8048 ) -> Result<String, String> {
8049 let current = self
8050 .crdt_column_bytes(table, row_id, column)?
8051 .unwrap_or_default();
8052 let update = crate::crdt::insert_text(¤t, name, index, value)?;
8053 self.crdt_push_update(table, row_id, column, &update)
8054 }
8055
8056 #[cfg(feature = "crdt-yjs")]
8060 pub fn crdt_delete_text(
8061 &mut self,
8062 table: &str,
8063 row_id: &str,
8064 column: &str,
8065 name: &str,
8066 index: u32,
8067 len: u32,
8068 ) -> Result<String, String> {
8069 let current = self
8070 .crdt_column_bytes(table, row_id, column)?
8071 .unwrap_or_default();
8072 let update = crate::crdt::delete_text(¤t, name, index, len)?;
8073 self.crdt_push_update(table, row_id, column, &update)
8074 }
8075
8076 #[cfg(feature = "crdt-yjs")]
8081 pub fn crdt_apply_update(
8082 &mut self,
8083 table: &str,
8084 row_id: &str,
8085 column: &str,
8086 update: &[u8],
8087 ) -> Result<String, String> {
8088 let current = self
8089 .crdt_column_bytes(table, row_id, column)?
8090 .unwrap_or_default();
8091 let next = crate::crdt::apply_update(¤t, update)?;
8092 self.crdt_push_update(table, row_id, column, &next)
8093 }
8094
8095 #[cfg(feature = "crdt-yjs")]
8102 fn crdt_push_update(
8103 &mut self,
8104 table: &str,
8105 row_id: &str,
8106 column: &str,
8107 crdt_bytes: &[u8],
8108 ) -> Result<String, String> {
8109 let mut bytes_obj = Map::new();
8110 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
8111 let mut partial = Map::new();
8112 partial.insert(column.to_owned(), Value::Object(bytes_obj));
8113 self.patch(table, row_id, partial, None)
8114 }
8115
8116 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
8131 query_connection(&self.conn, sql, params)
8132 }
8133
8134 #[cfg(feature = "bench-internals")]
8136 #[doc(hidden)]
8137 pub fn benchmark_connection(&mut self) -> &mut Connection {
8138 &mut self.conn
8139 }
8140
8141 #[cfg(feature = "bench-internals")]
8143 #[doc(hidden)]
8144 pub fn benchmark_phases(
8145 &self,
8146 enabled: Option<bool>,
8147 reset: bool,
8148 ) -> Result<Option<Value>, String> {
8149 self.benchmark_phases.configure(enabled, reset)?;
8150 Ok(self.benchmark_phases.snapshot())
8151 }
8152
8153 pub fn query_snapshot(
8155 &mut self,
8156 sql: &str,
8157 params: &[QueryValue],
8158 coverage: &[WindowCoverage],
8159 ) -> Result<QuerySnapshot, String> {
8160 snapshot_connection(&self.conn, sql, params, coverage)
8161 }
8162
8163 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
8166 #[cfg(feature = "bench-internals")]
8167 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
8168 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
8169 let mut frames = vec![Frame::ReqHeader {
8170 client_id: self.client_id.clone(),
8171 schema_version: self.schema.version,
8172 log_epoch: log_epoch.clone(),
8173 }];
8174 let mut pushed_ids = Vec::new();
8175 let mut ops_in_request = 0usize;
8176 let mut deferred_commits = 0usize;
8177 #[cfg(feature = "bench-internals")]
8178 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
8179 for (index, commit) in self
8180 .outbox
8181 .iter()
8182 .take(if log_epoch.is_some() {
8183 self.outbox.len()
8184 } else {
8185 0
8186 })
8187 .enumerate()
8188 {
8189 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
8194 deferred_commits = self.outbox.len() - index;
8195 break;
8196 }
8197 ops_in_request += commit.ops.len();
8198 let operations = commit
8199 .ops
8200 .iter()
8201 .map(|op| {
8202 let payload = op.values.as_ref().and_then(|values| {
8203 let table = self.schema.table(&op.table)?;
8204 let fallback = self.stored_key_fallback(table, &op.row_id);
8213 encode_sparse_row_json(
8214 table,
8215 &op.row_id,
8216 values,
8217 &self.encryption,
8218 fallback.as_ref(),
8219 )
8220 .ok()
8221 });
8222 ssp2::model::Operation {
8223 table: op.table.clone(),
8224 row_id: op.row_id.clone(),
8225 op: if op.upsert { Op::Upsert } else { Op::Delete },
8226 base_version: op.base_version,
8227 payload,
8228 }
8229 })
8230 .collect();
8231 frames.push(Frame::PushCommit {
8232 client_commit_id: commit.client_commit_id.clone(),
8233 operations,
8234 });
8235 pushed_ids.push(commit.client_commit_id.clone());
8236 }
8237 #[cfg(feature = "bench-internals")]
8238 drop(outbox_phase);
8239 let accept = self.limits.accept.unwrap_or(if url_capable {
8242 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
8243 } else {
8244 DEFAULT_ACCEPT
8245 });
8246 frames.push(Frame::PullHeader {
8247 limit_commits: self.limits.limit_commits.unwrap_or(0),
8248 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
8249 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
8250 accept,
8251 });
8252 let mut fresh = Vec::new();
8253 for sub in &self.subs {
8254 if sub.state != SubState::Active {
8255 continue;
8256 }
8257 let mut scopes = sub.requested.clone();
8258 sort_scope_map(&mut scopes);
8259 frames.push(Frame::Subscription {
8260 id: sub.id.clone(),
8261 table: sub.table.clone(),
8262 scopes,
8263 params: sub.params.clone().map(RawJson),
8264 cursor: sub.cursor,
8265 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
8266 });
8267 fresh.push((
8268 sub.id.clone(),
8269 sub.cursor < 0 && sub.bootstrap_state.is_none(),
8270 ));
8271 }
8272 let message = Message {
8273 wire_version: WIRE_VERSION,
8274 msg_kind: MsgKind::Request,
8275 frames,
8276 };
8277 (
8278 message,
8279 RequestMeta {
8280 pushed_ids,
8281 fresh,
8282 accept,
8283 deferred_commits,
8284 },
8285 )
8286 }
8287
8288 pub fn progress(&self) -> ProgressObserver {
8292 self.progress.clone()
8293 }
8294
8295 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
8296 self.progress.start();
8297 let started_at_ms = self.clock_now_ms();
8298 let outcome = self.sync_inner(transport);
8299 self.progress.update(|p| match &outcome {
8300 SyncOutcome::Ok(report) if report.failed.is_empty() => {
8301 p.state = ProgressState::Complete
8302 }
8303 SyncOutcome::Ok(_) => {
8304 p.state = ProgressState::Failed;
8305 p.error_code = Some("sync.scope_revoked".into());
8306 }
8307 SyncOutcome::Failed { error_code, .. } => {
8308 p.state = ProgressState::Failed;
8309 p.error_code = Some(Self::diagnostic_code(error_code));
8310 }
8311 });
8312 let completed_at_ms = self.clock_now_ms();
8313 self.last_round = Some(match &outcome {
8314 SyncOutcome::Ok(report) => DiagnosticLastRound {
8315 status: "succeeded".to_owned(),
8316 started_at_ms,
8317 completed_at_ms,
8318 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
8319 counters: Some(DiagnosticRoundCounters {
8320 pushed: report.pushed,
8321 applied: report.applied.len(),
8322 rejected: report.rejected.len(),
8323 retryable: report.retryable.len(),
8324 conflicts: report.conflicts,
8325 commits_applied: report.commits_applied,
8326 segment_rows_applied: report.segment_rows_applied,
8327 bootstrapping: report.bootstrapping.len(),
8328 resets: report.resets.len(),
8329 revoked: report.revoked.len(),
8330 failed: report.failed.len(),
8331 deferred_commits: report.deferred_commits,
8332 }),
8333 error_code: None,
8334 },
8335 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
8336 status: "failed".to_owned(),
8337 started_at_ms,
8338 completed_at_ms,
8339 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
8340 counters: None,
8341 error_code: Some(Self::diagnostic_code(error_code)),
8342 },
8343 });
8344 outcome
8345 }
8346
8347 fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
8348 if self.stopped {
8349 return SyncOutcome::Ok(SyncReport {
8352 schema_floor: self.schema_floor.clone(),
8353 ..SyncReport::default()
8354 });
8355 }
8356 self.set_sync_needed(false, false);
8359 if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
8362 if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
8363 if Self::retryable_transport_code(&code) {
8364 self.schedule_background_retry();
8365 }
8366 return SyncOutcome::Failed {
8367 error_code: code,
8368 message,
8369 };
8370 }
8371 }
8372 if let Err(message) = self.drain_pending_evictions() {
8373 return SyncOutcome::Failed {
8374 error_code: "storage.failed".into(),
8375 message,
8376 };
8377 }
8378 if let Err(message) = self.drop_unencodable_outbox() {
8382 return SyncOutcome::Failed {
8383 error_code: "storage.failed".into(),
8384 message,
8385 };
8386 }
8387 let (message, meta) = self.build_request(transport.supports_url_fetch());
8388 let request_bytes = {
8389 #[cfg(feature = "bench-internals")]
8390 let _phase = self.benchmark_phases.start(Phase::RequestEncode);
8391 encode_message(&message)
8392 };
8393 let round = if self.realtime_connected {
8397 transport.realtime_sync(&request_bytes)
8398 } else {
8399 transport.sync(&request_bytes)
8400 };
8401 let response_bytes = match round {
8402 Ok(bytes) => bytes,
8403 Err(TransportError { code, message }) => {
8404 self.record_lease_error(&code);
8407 if Self::retryable_transport_code(&code) {
8408 self.schedule_background_retry();
8409 }
8410 return SyncOutcome::Failed {
8411 error_code: code,
8412 message,
8413 };
8414 }
8415 };
8416 #[cfg(feature = "bench-internals")]
8417 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
8418 let response = match decode_message(&response_bytes) {
8419 Ok(message) => message,
8420 Err(error) => {
8421 return SyncOutcome::Failed {
8424 error_code: error.code.as_str().to_owned(),
8425 message: error.detail,
8426 };
8427 }
8428 };
8429 #[cfg(feature = "bench-internals")]
8430 drop(decode_phase);
8431 if response.msg_kind != MsgKind::Response {
8432 return SyncOutcome::Failed {
8433 error_code: "sync.invalid_request".to_owned(),
8434 message: "expected a response message".to_owned(),
8435 };
8436 }
8437 let mut outcome = self.process_response(transport, response, &meta);
8438 if let SyncOutcome::Ok(report) = &mut outcome {
8439 report.deferred_commits = meta.deferred_commits;
8440 }
8441 match &outcome {
8442 SyncOutcome::Ok(_) => self.reset_background_retry(),
8443 SyncOutcome::Failed { error_code, .. }
8444 if Self::retryable_transport_code(error_code) =>
8445 {
8446 self.schedule_background_retry();
8447 }
8448 SyncOutcome::Failed { .. } => {}
8449 }
8450 if meta.deferred_commits > 0 {
8451 self.set_sync_needed(true, true);
8454 }
8455 outcome
8456 }
8457
8458 pub fn sync_until_idle(
8459 &mut self,
8460 transport: &mut dyn Transport,
8461 max_rounds: Option<u32>,
8462 ) -> SyncOutcome {
8463 let rounds = max_rounds.unwrap_or(20).max(1);
8464 let mut aggregate = SyncReport::default();
8465 let mut spent = 0;
8466 while spent < rounds {
8467 spent += 1;
8468 let before = self
8469 .subs
8470 .iter()
8471 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
8472 .collect::<Vec<_>>();
8473 match self.sync(transport) {
8474 SyncOutcome::Failed {
8475 error_code,
8476 message,
8477 } => {
8478 return SyncOutcome::Failed {
8479 error_code,
8480 message,
8481 };
8482 }
8483 SyncOutcome::Ok(report) => {
8484 if max_rounds.is_none()
8485 && report.segment_rows_applied > 0
8486 && !report.bootstrapping.is_empty()
8487 && before
8488 != self
8489 .subs
8490 .iter()
8491 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
8492 .collect::<Vec<_>>()
8493 {
8494 spent = 0;
8495 }
8496 aggregate.pushed += report.pushed;
8497 aggregate.applied.extend(report.applied.iter().cloned());
8498 aggregate.rejected.extend(report.rejected.iter().cloned());
8499 aggregate.retryable.extend(report.retryable.iter().cloned());
8500 aggregate.conflicts += report.conflicts;
8501 aggregate.commits_applied += report.commits_applied;
8502 aggregate.segment_rows_applied += report.segment_rows_applied;
8503 aggregate.bootstrapping = report.bootstrapping.clone();
8504 aggregate.resets.extend(report.resets.iter().cloned());
8505 aggregate.revoked.extend(report.revoked.iter().cloned());
8506 aggregate.failed.extend(report.failed.iter().cloned());
8507 aggregate.deferred_commits = report.deferred_commits;
8508 if report.schema_floor.is_some() {
8509 aggregate.schema_floor = report.schema_floor.clone();
8510 }
8511 let more = !report.bootstrapping.is_empty()
8517 || report.commits_applied > 0
8518 || report.segment_rows_applied > 0
8519 || !report.resets.is_empty()
8520 || self.sync_needed;
8521 if !more {
8522 return SyncOutcome::Ok(aggregate);
8523 }
8524 }
8525 }
8526 }
8527 SyncOutcome::Failed {
8528 error_code: "sync.invalid_request".into(),
8529 message: "sync did not reach idle within the round budget".into(),
8530 }
8531 }
8532
8533 fn process_response(
8534 &mut self,
8535 transport: &mut dyn Transport,
8536 response: Message,
8537 meta: &RequestMeta,
8538 ) -> SyncOutcome {
8539 #[cfg(feature = "bench-internals")]
8540 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
8541 let mut report = SyncReport {
8542 pushed: meta.pushed_ids.len() as u32,
8543 ..SyncReport::default()
8544 };
8545 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
8546 HashMap::new();
8547 let pushed_ids = meta
8548 .pushed_ids
8549 .iter()
8550 .map(String::as_str)
8551 .collect::<HashSet<_>>();
8552 let mut last_final_push_result_id: Option<String> = None;
8553 for frame in &response.frames {
8554 match frame {
8555 Frame::PushResultDetails {
8556 client_commit_id,
8557 entries,
8558 } => {
8559 let details = rejection_details_by_commit
8560 .entry(client_commit_id.clone())
8561 .or_default();
8562 for entry in entries {
8563 let parsed = match RejectionDetails::parse(&entry.details.0) {
8564 Ok(value) => value,
8565 Err(message) => {
8566 return SyncOutcome::Failed {
8567 error_code: "sync.invalid_request".to_owned(),
8568 message,
8569 };
8570 }
8571 };
8572 details.insert(entry.op_index, parsed);
8573 }
8574 }
8575 Frame::PushResult {
8576 client_commit_id,
8577 status,
8578 results,
8579 ..
8580 } if pushed_ids.contains(client_commit_id.as_str())
8581 && Self::push_result_is_final(*status, results) =>
8582 {
8583 last_final_push_result_id = Some(client_commit_id.clone());
8584 }
8585 _ => {}
8586 }
8587 }
8588 let mut frames = response.frames.into_iter().peekable();
8589 if response.wire_version < 2 {
8590 return SyncOutcome::Failed {
8591 error_code: "client.invalid_host_response".to_owned(),
8592 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
8593 };
8594 }
8595 match frames.next() {
8596 Some(Frame::RespHeader {
8597 required_schema_version,
8598 latest_schema_version,
8599 log_epoch,
8600 reset_required,
8601 }) => {
8602 let Some(log_epoch) = log_epoch else {
8603 return SyncOutcome::Failed {
8604 error_code: "client.invalid_host_response".to_owned(),
8605 message: "response header omits logEpoch".to_owned(),
8606 };
8607 };
8608 let Some(reset_required) = reset_required else {
8609 return SyncOutcome::Failed {
8610 error_code: "client.invalid_host_response".to_owned(),
8611 message: "response header omits resetRequired".to_owned(),
8612 };
8613 };
8614 if let Some(required) = required_schema_version {
8615 let floor = SchemaFloor {
8618 required_schema_version: Some(required),
8619 latest_schema_version,
8620 };
8621 self.set_schema_floor(Some(floor.clone()));
8622 report.schema_floor = Some(floor);
8623 return SyncOutcome::Ok(report);
8624 }
8625 if reset_required {
8626 if frames.next().is_some() {
8627 return SyncOutcome::Failed {
8628 error_code: "client.invalid_host_response".to_owned(),
8629 message: "log-epoch reset response contains body frames".to_owned(),
8630 };
8631 }
8632 match self.run_log_epoch_reset(&log_epoch) {
8633 Ok(resets) => {
8634 report.resets = resets;
8635 return SyncOutcome::Ok(report);
8636 }
8637 Err(message) => {
8638 return SyncOutcome::Failed {
8639 error_code: "sync.local_corrupt".to_owned(),
8640 message,
8641 };
8642 }
8643 }
8644 }
8645 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
8646 return SyncOutcome::Failed {
8647 error_code: "client.invalid_host_response".to_owned(),
8648 message: "server changed logEpoch without requiring a reset".to_owned(),
8649 };
8650 }
8651 }
8652 _ => {
8653 return SyncOutcome::Failed {
8654 error_code: "sync.invalid_request".to_owned(),
8655 message: "response does not start with RESP_HEADER".to_owned(),
8656 };
8657 }
8658 }
8659
8660 let mut failure: Option<(String, String)> = None;
8661 while let Some(frame) = frames.next() {
8662 match frame {
8663 frame @ Frame::PushResult { .. } => {
8664 let successful = matches!(
8665 &frame,
8666 Frame::PushResult {
8667 status: PushStatus::Applied | PushStatus::Cached,
8668 ..
8669 }
8670 );
8671 let mut results = vec![frame];
8672 if successful {
8673 while matches!(
8674 frames.peek(),
8675 Some(Frame::PushResult {
8676 status: PushStatus::Applied | PushStatus::Cached,
8677 ..
8678 })
8679 ) {
8680 if let Some(next) = frames.next() {
8681 results.push(next);
8682 }
8683 }
8684 }
8685 if self
8686 .handle_push_results(
8687 &results,
8688 &pushed_ids,
8689 &rejection_details_by_commit,
8690 &mut report,
8691 last_final_push_result_id.as_deref(),
8692 )
8693 .is_err()
8694 {
8695 failure = Some((
8696 "client.outcome_persistence_failed".to_owned(),
8697 "local commit outcome could not be persisted".to_owned(),
8698 ));
8699 break;
8700 }
8701 }
8702 Frame::PushResultDetails { .. } => {}
8703 Frame::SubStart {
8704 id,
8705 status,
8706 reason_code,
8707 effective_scopes,
8708 bootstrap: _,
8709 } => {
8710 let mut body = Vec::new();
8711 let mut sub_end: Option<(i64, Option<String>)> = None;
8712 for inner in frames.by_ref() {
8713 match inner {
8714 Frame::SubEnd {
8715 next_cursor,
8716 bootstrap_state,
8717 } => {
8718 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
8719 break;
8720 }
8721 Frame::Unknown { .. } => {}
8722 other => body.push(other),
8723 }
8724 }
8725 if let Err(SectionError::Abort(code, message)) = self.process_section(
8726 transport,
8727 &id,
8728 status,
8729 &reason_code,
8730 effective_scopes,
8731 body,
8732 sub_end,
8733 meta,
8734 &mut report,
8735 ) {
8736 failure = Some((code, message));
8737 break;
8738 }
8739 }
8740 Frame::Lease {
8741 lease_id,
8742 expires_at_ms,
8743 } => {
8744 self.set_lease_state(Some(LeaseState {
8747 lease_id: Some(lease_id),
8748 expires_at_ms: Some(expires_at_ms),
8749 error_code: None,
8750 }));
8751 }
8752 Frame::Error { code, message, .. } => {
8753 failure = Some((code, message));
8756 break;
8757 }
8758 Frame::Unknown { .. } => {}
8759 _ => {
8760 failure = Some((
8761 "sync.invalid_request".to_owned(),
8762 "unexpected frame in response".to_owned(),
8763 ));
8764 break;
8765 }
8766 }
8767 }
8768
8769 if self.overlay_dirty.get() {
8774 self.rebuild_overlay();
8775 }
8776 if let Some((error_code, message)) = failure {
8777 return SyncOutcome::Failed {
8778 error_code,
8779 message,
8780 };
8781 }
8782 if let Err(message) = self.drain_pending_evictions() {
8785 return SyncOutcome::Failed {
8786 error_code: "storage.failed".into(),
8787 message,
8788 };
8789 }
8790 self.ack_after_pull(transport);
8791 if self.upgrading && report.bootstrapping.is_empty() {
8794 self.set_upgrading(false);
8795 }
8796 SyncOutcome::Ok(report)
8797 }
8798
8799 fn handle_push_results(
8802 &mut self,
8803 frames: &[Frame],
8804 pushed_ids: &HashSet<&str>,
8805 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
8806 report: &mut SyncReport,
8807 last_final_push_result_id: Option<&str>,
8808 ) -> Result<(), String> {
8809 self.begin_observation("syncular_push_results")?;
8810 let conflict_count = self.conflicts.len();
8811 let rejection_count = self.rejections.len();
8812 let applied_count = report.applied.len();
8813 let rejected_count = report.rejected.len();
8814 let retryable_count = report.retryable.len();
8815 let was_dirty = self.overlay_dirty.get();
8816 let mut removed = Vec::new();
8817 let mut batch = ChangeAccumulator::default();
8818 let persisted = (|| {
8819 for frame in frames {
8820 let Frame::PushResult {
8821 client_commit_id,
8822 status,
8823 results,
8824 ..
8825 } = frame
8826 else {
8827 return Err("unexpected acknowledgement frame".to_owned());
8828 };
8829 if !pushed_ids.contains(client_commit_id.as_str()) {
8830 continue;
8831 }
8832 let Some(index) = self
8833 .outbox
8834 .iter()
8835 .position(|commit| commit.client_commit_id == *client_commit_id)
8836 else {
8837 continue;
8838 };
8839 let status = *status;
8840 let rejection_details = rejection_details_by_commit.get(client_commit_id);
8841 let operations = self.outbox[index].ops.clone();
8842 match status {
8843 PushStatus::Applied | PushStatus::Cached => {
8844 let journal_results = results
8847 .iter()
8848 .map(|result| {
8849 let op_index = match result {
8850 OpResult::Applied { op_index }
8851 | OpResult::Conflict { op_index, .. }
8852 | OpResult::Error { op_index, .. } => *op_index,
8853 };
8854 CommitOperationOutcome::Applied { op_index }
8855 })
8856 .collect::<Vec<_>>();
8857 let outcome_status = if status == PushStatus::Applied {
8858 CommitOutcomeStatus::Applied
8859 } else {
8860 CommitOutcomeStatus::Cached
8861 };
8862 let persisted = self
8863 .persist_commit_outcome(
8864 client_commit_id,
8865 outcome_status,
8866 &journal_results,
8867 None,
8868 )
8869 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
8870 persisted?;
8871 self.overlay_dirty.set(true);
8872 batch.status = true;
8873 batch.outcomes = true;
8874 }
8875 PushStatus::Rejected => {
8876 if results.iter().any(|result| {
8877 matches!(
8878 result,
8879 OpResult::Error {
8880 code,
8881 retryable: true,
8882 ..
8883 } if code == "sync.idempotency_cache_miss"
8884 )
8885 }) {
8886 report.retryable.push(client_commit_id.to_owned());
8889 continue;
8890 }
8891
8892 let mut journal_results = Vec::with_capacity(results.len());
8893 let mut conflicts = Vec::new();
8894 let mut rejections = Vec::new();
8895 for result in results {
8896 match result {
8897 OpResult::Applied { op_index } => {
8898 journal_results.push(CommitOperationOutcome::Applied {
8899 op_index: *op_index,
8900 });
8901 }
8902 OpResult::Conflict {
8903 op_index,
8904 code,
8905 message,
8906 server_version,
8907 server_row,
8908 conflict_columns: conflict_columns_bitmap,
8909 } => {
8910 let operation = operations
8911 .get(*op_index as usize)
8912 .map(CommitOperation::from);
8913 let (table, row_id) = operation
8914 .as_ref()
8915 .map(|op| (op.table.clone(), op.row_id.clone()))
8916 .unwrap_or_default();
8917 let server_row_json = self
8918 .schema
8919 .table(&table)
8920 .and_then(|t| {
8921 decode_row_bytes(t, server_row, &self.encryption)
8922 .ok()
8923 .map(|row| (t, row))
8924 })
8925 .map(|(t, row)| {
8926 let mut map = Map::new();
8927 for (i, column) in t.columns.iter().enumerate() {
8928 map.insert(
8929 column.name.clone(),
8930 column_value_to_json(
8931 row.get(i).unwrap_or(&None),
8932 ),
8933 );
8934 }
8935 map
8936 })
8937 .unwrap_or_default();
8938 let conflict_columns = self
8941 .schema
8942 .table(&table)
8943 .map(|t| {
8944 t.columns
8945 .iter()
8946 .enumerate()
8947 .filter(|(index, _)| {
8948 conflict_columns_bitmap
8949 .get(index / 8)
8950 .is_some_and(|byte| {
8951 byte & (1 << (index % 8)) != 0
8952 })
8953 })
8954 .map(|(_, column)| column.name.clone())
8955 .collect::<Vec<_>>()
8956 })
8957 .unwrap_or_default();
8958 let conflict = ConflictRecord {
8959 client_commit_id: client_commit_id.to_owned(),
8960 op_index: *op_index,
8961 table,
8962 row_id,
8963 code: code.clone(),
8964 message: message.clone(),
8965 server_version: *server_version,
8966 server_row: server_row_json,
8967 conflict_columns,
8968 operation,
8969 };
8970 journal_results.push(CommitOperationOutcome::Conflict {
8971 conflict: conflict.clone(),
8972 });
8973 conflicts.push(conflict);
8974 }
8975 OpResult::Error {
8976 op_index,
8977 code,
8978 message,
8979 retryable,
8980 } => {
8981 let rejection = RejectionRecord {
8982 client_commit_id: client_commit_id.to_owned(),
8983 op_index: *op_index,
8984 code: code.clone(),
8985 message: message.clone(),
8986 retryable: *retryable,
8987 details: rejection_details
8988 .and_then(|details| details.get(op_index))
8989 .cloned(),
8990 operation: operations
8991 .get(*op_index as usize)
8992 .map(CommitOperation::from),
8993 };
8994 journal_results.push(CommitOperationOutcome::Error {
8995 rejection: rejection.clone(),
8996 });
8997 rejections.push(rejection);
8998 }
8999 }
9000 }
9001 let outcome_status = if conflicts.is_empty() {
9002 CommitOutcomeStatus::Rejected
9003 } else {
9004 CommitOutcomeStatus::Conflict
9005 };
9006 let persisted = self
9007 .persist_commit_outcome(
9008 client_commit_id,
9009 outcome_status,
9010 &journal_results,
9011 Some(&operations),
9012 )
9013 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9014 persisted?;
9015 batch.conflicts = !conflicts.is_empty();
9016 batch.rejections = !rejections.is_empty();
9017 batch.status = true;
9018 batch.outcomes = true;
9019 self.conflicts.extend(conflicts);
9020 self.rejections.extend(rejections);
9021 self.overlay_dirty.set(true);
9022 }
9023 }
9024 removed.push((index, self.outbox.remove(index)));
9025 if batch.status {
9026 for operation in &operations {
9027 if !self.record_row_scopes(
9028 &mut batch,
9029 &operation.table,
9030 &operation.row_id,
9031 false,
9032 ) {
9033 batch.table(&operation.table);
9034 }
9035 }
9036 }
9037 match status {
9038 PushStatus::Applied | PushStatus::Cached => {
9039 report.applied.push(client_commit_id.to_owned())
9040 }
9041 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
9042 }
9043 }
9044 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)) {
9045 self.prune_commit_outcomes()?;
9046 }
9047 self.finish_observation("syncular_push_results", batch)
9048 })();
9049 if let Err(error) = persisted {
9050 self.rollback_observation("syncular_push_results");
9051 for (index, commit) in removed.into_iter().rev() {
9052 self.outbox.insert(index, commit);
9053 }
9054 self.conflicts.truncate(conflict_count);
9055 self.rejections.truncate(rejection_count);
9056 self.overlay_dirty.set(was_dirty);
9057 report.applied.truncate(applied_count);
9058 report.rejected.truncate(rejected_count);
9059 report.retryable.truncate(retryable_count);
9060 return Err(error);
9061 }
9062 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
9063 Ok(())
9064 }
9065
9066 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
9067 status != PushStatus::Rejected
9068 || !results.iter().any(|result| {
9069 matches!(
9070 result,
9071 OpResult::Error {
9072 code,
9073 retryable: true,
9074 ..
9075 } if code == "sync.idempotency_cache_miss"
9076 )
9077 })
9078 }
9079
9080 #[allow(clippy::too_many_arguments)]
9083 fn process_section(
9084 &mut self,
9085 transport: &mut dyn Transport,
9086 id: &str,
9087 status: SubStatus,
9088 reason_code: &str,
9089 effective_scopes: Vec<(String, Vec<String>)>,
9090 body: Vec<Frame>,
9091 sub_end: Option<(i64, Option<String>)>,
9092 meta: &RequestMeta,
9093 report: &mut SyncReport,
9094 ) -> Result<(), SectionError> {
9095 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
9096 return Ok(()); };
9098 if status != SubStatus::Active && sub_end.is_none() {
9099 return Err(SectionError::Abort(
9100 "sync.invalid_request".into(),
9101 "subscription section without SUB_END".into(),
9102 ));
9103 }
9104 match status {
9105 SubStatus::Revoked => {
9106 self.begin_observation("syncular_revocation")
9107 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9108 let previous = self.subs[sub_index].clone();
9109 let was_dirty = self.overlay_dirty.get();
9110 let previous_outbox = self.outbox.clone();
9111 let rejection_count = self.rejections.len();
9112 let revoked_count = report.revoked.len();
9113 let failed_count = report.failed.len();
9114 let result =
9115 (|| {
9116 let mut batch = ChangeAccumulator::default();
9117 let registered = self.window_unit_by_sub_id(id);
9118 let (table, effective) = {
9120 let sub = &self.subs[sub_index];
9121 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9122 };
9123 let purged = self.purge_scope_rows(&table, &effective);
9124 match purged {
9125 Ok(()) => {
9126 self.record_scope_map(&mut batch, &table, &effective);
9127 let sub = &mut self.subs[sub_index];
9128 sub.state = SubState::Revoked;
9129 sub.reason_code = Some(if reason_code.is_empty() {
9130 "sync.scope_revoked".to_owned()
9131 } else {
9132 reason_code.to_owned()
9133 });
9134 report.revoked.push(id.to_owned());
9135 let doomed_effective = effective;
9136 let sub_table = table;
9137 self.persist_sub(&self.subs[sub_index].clone()).map_err(
9138 |message| SectionError::Abort("storage.failed".into(), message),
9139 )?;
9140 let dropped = self
9141 .drop_doomed_outbox(&sub_table, &doomed_effective)
9142 .map_err(|message| {
9143 SectionError::Abort("storage.failed".to_owned(), message)
9144 })?;
9145 if dropped {
9146 batch.status = true;
9147 batch.rejections = true;
9148 batch.outcomes = true;
9149 }
9150 self.delete_unreferenced_cached_blobs();
9153 }
9154 Err(()) => {
9155 let sub = &mut self.subs[sub_index];
9158 sub.state = SubState::Failed;
9159 sub.reason_code = Some("sync.scope_revoked".to_owned());
9160 report.failed.push(id.to_owned());
9161 self.persist_sub(&self.subs[sub_index].clone()).map_err(
9162 |message| SectionError::Abort("storage.failed".into(), message),
9163 )?;
9164 }
9165 }
9166 if let Some((base_key, unit)) = registered {
9167 batch.window(&base_key, &self.subs[sub_index].table, &unit);
9168 }
9169 self.rebuild_overlay_if_dirty();
9170 self.finish_observation("syncular_revocation", batch)
9171 .map_err(|message| {
9172 SectionError::Abort("storage.failed".to_owned(), message)
9173 })?;
9174 Ok(())
9175 })();
9176 if result.is_err() {
9177 self.rollback_observation("syncular_revocation");
9178 self.subs[sub_index] = previous;
9179 self.overlay_dirty.set(was_dirty);
9180 self.outbox = previous_outbox;
9181 self.rejections.truncate(rejection_count);
9182 report.revoked.truncate(revoked_count);
9183 report.failed.truncate(failed_count);
9184 }
9185 result
9186 }
9187 SubStatus::Reset => {
9188 self.begin_observation("syncular_reset")
9189 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9190 let previous = self.subs[sub_index].clone();
9191 let was_dirty = self.overlay_dirty.get();
9192 let reset_count = report.resets.len();
9193 let result = (|| {
9194 let mut batch = ChangeAccumulator::default();
9195 let registered = self.window_unit_by_sub_id(id);
9196 let sub = &mut self.subs[sub_index];
9199 sub.cursor = -1;
9200 sub.bootstrap_state = None;
9201 report.resets.push(id.to_owned());
9202 self.persist_sub(&self.subs[sub_index].clone())
9203 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9204 if let Some((base_key, unit)) = registered {
9205 batch.window(&base_key, &self.subs[sub_index].table, &unit);
9206 }
9207 self.finish_observation("syncular_reset", batch)
9208 .map_err(|message| {
9209 SectionError::Abort("storage.failed".to_owned(), message)
9210 })?;
9211 Ok(())
9212 })();
9213 if result.is_err() {
9214 self.rollback_observation("syncular_reset");
9215 self.subs[sub_index] = previous;
9216 self.overlay_dirty.set(was_dirty);
9217 report.resets.truncate(reset_count);
9218 }
9219 result
9220 }
9221 SubStatus::Active => {
9222 let fresh = meta
9223 .fresh
9224 .iter()
9225 .find(|(fid, _)| fid == id)
9226 .map(|(_, fresh)| *fresh)
9227 .unwrap_or(false);
9228 let previous = self.subs[sub_index].clone();
9229 self.subs[sub_index].effective = Some(effective_scopes);
9230 let outcome = self
9231 .apply_section_body(transport, sub_index, body, fresh, meta, report)
9232 .and_then(|()| {
9233 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
9234 SectionError::Abort(
9235 "sync.invalid_request".into(),
9236 "subscription section without SUB_END".into(),
9237 )
9238 })?;
9239 self.apply_sub_end(sub_index, cursor, bootstrap_state)
9240 .map_err(|message| {
9241 SectionError::Abort("storage.failed".into(), message)
9242 })
9243 });
9244 match outcome {
9245 Ok(()) => {
9246 if self.subs[sub_index].bootstrap_state.is_some() {
9247 report.bootstrapping.push(id.to_owned());
9248 }
9249 Ok(())
9250 }
9251 Err(SectionError::FailClosed) => {
9252 self.subs[sub_index] = previous.clone();
9253 self.begin_observation("syncular_section_failure")
9254 .map_err(|message| {
9255 SectionError::Abort("storage.failed".into(), message)
9256 })?;
9257 let persisted = (|| {
9258 let mut batch = ChangeAccumulator::default();
9259 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
9260 batch.window(&base_key, &self.subs[sub_index].table, &unit);
9261 }
9262 self.subs[sub_index].state = SubState::Failed;
9263 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
9264 self.persist_sub(&self.subs[sub_index])?;
9265 self.finish_observation("syncular_section_failure", batch)
9266 })();
9267 if let Err(message) = persisted {
9268 self.rollback_observation("syncular_section_failure");
9269 self.subs[sub_index] = previous;
9270 return Err(SectionError::Abort("storage.failed".into(), message));
9271 }
9272 report.failed.push(id.to_owned());
9273 Ok(())
9274 }
9275 Err(error) => {
9276 self.subs[sub_index] = previous;
9279 Err(error)
9280 }
9281 }
9282 }
9283 }
9284 }
9285
9286 fn apply_sub_end(
9287 &mut self,
9288 sub_index: usize,
9289 cursor: i64,
9290 bootstrap_state: Option<String>,
9291 ) -> Result<(), String> {
9292 #[cfg(feature = "bench-internals")]
9293 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
9294 let previous = self.subs[sub_index].clone();
9295 self.begin_observation("syncular_sub_end")?;
9296 let persisted = (|| {
9297 let mut batch = ChangeAccumulator::default();
9298 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
9299 && cursor >= 0
9300 && bootstrap_state.is_none();
9301 if completed {
9302 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
9303 batch.window(&base_key, &previous.table, &unit);
9304 }
9305 }
9306 let sub = &mut self.subs[sub_index];
9307 sub.cursor = cursor;
9308 sub.bootstrap_state = bootstrap_state;
9309 sub.synced_once = true;
9310 self.persist_sub(&self.subs[sub_index])?;
9311 self.finish_observation("syncular_sub_end", batch)
9312 })();
9313 if persisted.is_err() {
9314 self.rollback_observation("syncular_sub_end");
9315 self.subs[sub_index] = previous;
9316 }
9317 persisted
9318 }
9319
9320 fn apply_section_body(
9321 &mut self,
9322 transport: &mut dyn Transport,
9323 sub_index: usize,
9324 body: Vec<Frame>,
9325 fresh: bool,
9326 meta: &RequestMeta,
9327 report: &mut SyncReport,
9328 ) -> Result<(), SectionError> {
9329 let mut saw_segment = false;
9330 for frame in body {
9331 match frame {
9332 Frame::Commit {
9333 tables, changes, ..
9334 } => {
9335 self.apply_commit_frame(&tables, &changes)
9336 .map_err(|(c, m)| SectionError::Abort(c, m))?;
9337 report.commits_applied += 1;
9338 }
9339 Frame::SegmentInline { payload } => {
9340 let segment = decode_rows_segment(&payload)
9341 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
9342 self.progress.update(|p| {
9343 p.phase = ProgressPhase::Import;
9344 p.subscription_id = Some(self.subs[sub_index].id.clone());
9345 p.table = Some(self.subs[sub_index].table.clone());
9346 p.segment_id = None;
9347 p.bytes_received = payload.len() as u64;
9348 p.bytes_total = Some(payload.len() as u64);
9349 p.rows_processed = 0;
9350 p.rows_total =
9351 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
9352 });
9353 let first = !saw_segment;
9354 saw_segment = true;
9355 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9356 report.segment_rows_applied += applied;
9357 }
9358 Frame::SegmentRef {
9359 segment_id,
9360 byte_length,
9361 media_type,
9362 table,
9363 row_count,
9364 as_of_commit_seq,
9365 scope_digest,
9366 row_cursor,
9367 next_row_cursor,
9368 url,
9369 url_expires_at_ms,
9370 ..
9371 } => {
9372 let advertised = match media_type {
9375 MediaType::Rows => {
9376 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
9377 || meta.accept & ACCEPT_INLINE_ROWS != 0
9378 }
9379 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
9380 };
9381 if !advertised {
9382 return Err(SectionError::Abort(
9383 "sync.invalid_request".to_owned(),
9384 format!(
9385 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
9386 media_type.name()
9387 ),
9388 ));
9389 }
9390 self.progress.update(|p| {
9391 p.phase = ProgressPhase::Download;
9392 p.subscription_id = Some(self.subs[sub_index].id.clone());
9393 p.table = Some(table.clone());
9394 p.segment_id = Some(segment_id.clone());
9395 p.bytes_received = 0;
9396 p.bytes_total = u64::try_from(byte_length).ok();
9397 p.rows_processed = 0;
9398 p.rows_total = u64::try_from(row_count).ok();
9399 });
9400 let observer = self.progress.clone();
9401 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
9402 let bytes = if let Some(url) = url {
9403 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
9408 return Err(SectionError::Abort(
9409 "sync.invalid_request".to_owned(),
9410 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
9411 .to_owned(),
9412 ));
9413 }
9414 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
9416 return Err(SectionError::Abort(
9417 "sync.segment_expired".to_owned(),
9418 format!(
9419 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
9420 ),
9421 ));
9422 }
9423 transport
9424 .fetch_url(&url, &mut on_progress)
9425 .map_err(|e| SectionError::Abort(e.code, e.message))?
9426 } else {
9427 let requested_scopes_json =
9428 canonical_scope_json(&self.subs[sub_index].requested);
9429 transport
9430 .download_segment(
9431 &SegmentRequest {
9432 segment_id: segment_id.clone(),
9433 table,
9434 requested_scopes_json,
9435 },
9436 &mut on_progress,
9437 )
9438 .map_err(|e| SectionError::Abort(e.code, e.message))?
9439 };
9440 on_progress(bytes.len() as u64);
9442 let digest = Sha256::digest(&bytes);
9443 let expected = segment_id
9444 .strip_prefix("sha256:")
9445 .unwrap_or(segment_id.as_str());
9446 if bytes_to_hex(&digest) != expected {
9447 return Err(SectionError::Abort(
9448 "sync.invalid_request".to_owned(),
9449 "segment bytes do not match the content address (§5.1)".to_owned(),
9450 ));
9451 }
9452 self.progress.update(|p| p.phase = ProgressPhase::Import);
9453 if media_type == MediaType::Sqlite {
9454 if row_cursor.is_some() || next_row_cursor.is_some() {
9457 return Err(SectionError::Abort(
9458 "sync.invalid_request".to_owned(),
9459 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
9460 .to_owned(),
9461 ));
9462 }
9463 let first = !saw_segment;
9464 saw_segment = true;
9465 let applied = self.apply_sqlite_segment(
9466 sub_index,
9467 &bytes,
9468 fresh && first,
9469 row_count,
9470 as_of_commit_seq,
9471 &scope_digest,
9472 )?;
9473 report.segment_rows_applied += applied;
9474 } else {
9475 let segment = decode_rows_segment(&bytes).map_err(|e| {
9476 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
9477 })?;
9478 let first = row_cursor.is_none();
9479 saw_segment = true;
9480 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9481 report.segment_rows_applied += applied;
9482 }
9483 }
9484 Frame::Unknown { .. } => {}
9485 Frame::Error { code, message, .. } => {
9486 return Err(SectionError::Abort(code, message))
9487 }
9488 _ => {
9489 return Err(SectionError::Abort(
9490 "sync.invalid_request".to_owned(),
9491 "unexpected frame inside a subscription section".to_owned(),
9492 ));
9493 }
9494 }
9495 }
9496 Ok(())
9497 }
9498
9499 fn apply_commit_frame(
9501 &mut self,
9502 tables: &[String],
9503 changes: &[ssp2::model::Change],
9504 ) -> Result<(), (String, String)> {
9505 #[cfg(feature = "bench-internals")]
9506 let _phase = self.benchmark_phases.start(Phase::CommitApply);
9507 let was_dirty = self.overlay_dirty.get();
9508 self.begin_observation("syncular_commit")
9509 .map_err(|message| ("storage.failed".into(), message))?;
9510 let applied = (|| {
9511 let mut batch = ChangeAccumulator::default();
9512 self.record_commit_changes(&mut batch, tables, changes);
9513 self.apply_commit_changes(tables, changes)?;
9514 self.rebuild_overlay_if_dirty();
9515 self.finish_observation("syncular_commit", batch)
9516 .map_err(|message| ("storage.failed".into(), message))
9517 })();
9518 if applied.is_err() {
9519 self.rollback_observation("syncular_commit");
9520 self.overlay_dirty.set(was_dirty);
9521 }
9522 applied
9523 }
9524
9525 fn apply_commit_changes(
9526 &mut self,
9527 tables: &[String],
9528 changes: &[ssp2::model::Change],
9529 ) -> Result<(), (String, String)> {
9530 let mirror_visible = !self.overlay_dirty.get()
9535 && (self.outbox.is_empty()
9536 || changes.iter().all(|change| {
9537 tables
9538 .get(change.table_index as usize)
9539 .and_then(|name| self.schema.table(name))
9540 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
9541 }));
9542 for change in changes {
9543 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
9544 (
9545 "sync.invalid_request".to_owned(),
9546 "change tableIndex out of range".to_owned(),
9547 )
9548 })?;
9549 let table = self.schema.table(table_name).ok_or_else(|| {
9550 (
9551 "sync.schema_mismatch".to_owned(),
9552 format!("change targets unknown table {table_name:?}"),
9553 )
9554 })?;
9555 match change.op {
9556 Op::Upsert => {
9557 let payload = change.row.as_ref().ok_or_else(|| {
9558 (
9559 "sync.invalid_request".to_owned(),
9560 "upsert change without row payload".to_owned(),
9561 )
9562 })?;
9563 let row = {
9565 #[cfg(feature = "bench-internals")]
9566 let _phase = self.benchmark_phases.start(Phase::RowDecode);
9567 decode_row_bytes(table, payload, &self.encryption)
9568 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
9569 };
9570 let version = change.row_version.unwrap_or(0);
9571 let table_name = table.name.clone();
9572 self.write_base_row(&table_name, &row, version)
9573 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9574 if mirror_visible {
9575 self.write_row(&visible_table(&table_name), &table_name, &row, version)
9576 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9577 }
9578 }
9579 Op::Delete => {
9580 self.delete_base_row(table_name, &change.row_id)
9581 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9582 if mirror_visible {
9583 let sql = format!(
9584 "DELETE FROM {} WHERE {}",
9585 visible_table(table_name),
9586 row_id_predicate(table)
9587 );
9588 self.conn
9589 .prepare_cached(&sql)
9590 .and_then(|mut statement| {
9591 statement.execute(rusqlite::params![change.row_id])
9592 })
9593 .map_err(|error| {
9594 ("sync.invalid_request".to_owned(), error.to_string())
9595 })?;
9596 }
9597 }
9598 }
9599 }
9600 if mirror_visible {
9601 if !self.outbox.is_empty() {
9602 let changed_rows: HashSet<(&str, &str)> = changes
9603 .iter()
9604 .map(|change| {
9605 (
9606 tables[change.table_index as usize].as_str(),
9607 change.row_id.as_str(),
9608 )
9609 })
9610 .collect();
9611 self.apply_outbox_ops(
9612 self.outbox
9613 .iter()
9614 .flat_map(|commit| &commit.ops)
9615 .filter(|op| {
9616 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
9617 }),
9618 );
9619 }
9620 self.overlay_dirty.set(false);
9621 }
9622 Ok(())
9623 }
9624
9625 fn apply_segment(
9630 &mut self,
9631 sub_index: usize,
9632 segment: &RowsSegment,
9633 first_fresh_page: bool,
9634 ) -> Result<u32, SectionError> {
9635 let (sub_table, effective) = {
9636 let sub = &self.subs[sub_index];
9637 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9638 };
9639 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
9640 SectionError::Abort(
9641 "sync.schema_mismatch".to_owned(),
9642 format!("subscription table {sub_table:?} is not in the client schema"),
9643 )
9644 })?;
9645 let matches = segment.table == table.name
9650 && segment.schema_version == self.schema.version
9651 && segment.columns.len() == table.wire_columns.len()
9652 && segment
9653 .columns
9654 .iter()
9655 .zip(table.wire_columns.iter())
9656 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
9657 if !matches {
9658 return Err(SectionError::Abort(
9659 "sync.schema_mismatch".to_owned(),
9660 "segment column table does not match the generated schema (§5.2)".to_owned(),
9661 ));
9662 }
9663 let mut applied = 0u32;
9664 let empty = Vec::new();
9665 let blocks = if segment.blocks.is_empty() {
9666 std::slice::from_ref(&empty)
9667 } else {
9668 &segment.blocks
9669 };
9670 for (index, block) in blocks.iter().enumerate() {
9671 let clear = first_fresh_page && index == 0;
9672 if !clear && block.is_empty() {
9673 continue;
9674 }
9675 let was_dirty = self.overlay_dirty.get();
9676 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
9677 && self
9678 .outbox
9679 .iter()
9680 .flat_map(|commit| &commit.ops)
9681 .any(|op| op.table == table.name);
9682 let mirror_visible = !reconcile_pending;
9683 self.begin_observation("syncular_segment_block")
9684 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9685 let outcome = (|| {
9686 self.rebuild_overlay_if_dirty();
9687 let mut changed_keys = Vec::new();
9688 let mut batch = ChangeAccumulator::default();
9689 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
9690 batch.table(&table.name);
9691 }
9692 if clear {
9693 self.purge_scope_rows(&table.name, &effective)
9694 .map_err(|()| SectionError::FailClosed)?;
9695 }
9696 for (row_index, row) in block.iter().enumerate() {
9697 let decrypted;
9698 let values = if table.has_encrypted_columns() {
9699 let mut values = row.values.clone();
9700 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
9701 .map_err(|message| {
9702 SectionError::Abort("client.decrypt_failed".into(), message)
9703 })?;
9704 decrypted = values;
9705 &decrypted
9706 } else {
9707 &row.values
9708 };
9709 self.write_base_row(&table.name, values, row.server_version)
9710 .map_err(|message| {
9711 SectionError::Abort("sync.invalid_request".into(), message)
9712 })?;
9713 if reconcile_pending {
9714 changed_keys.push(
9715 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
9716 |message| SectionError::Abort("storage.failed".into(), message),
9717 )?,
9718 );
9719 }
9720 if mirror_visible {
9721 self.write_row(
9722 &visible_table(&table.name),
9723 &table.name,
9724 values,
9725 row.server_version,
9726 )
9727 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9728 }
9729 let processed = u64::from(applied) + row_index as u64 + 1;
9730 if processed.is_multiple_of(1024) {
9731 self.progress.update(|p| p.rows_processed = processed);
9732 }
9733 }
9734 if reconcile_pending {
9735 self.reconcile_imported_rows(&table, &mut changed_keys)
9736 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9737 }
9738 self.apply_outbox_ops(
9739 self.outbox
9740 .iter()
9741 .flat_map(|commit| &commit.ops)
9742 .filter(|op| op.table == table.name),
9743 );
9744 self.overlay_dirty.set(false);
9745 self.finish_observation("syncular_segment_block", batch)
9746 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
9747 })();
9748 if let Err(error) = outcome {
9749 self.rollback_observation("syncular_segment_block");
9750 self.overlay_dirty.set(was_dirty);
9751 return Err(error);
9752 }
9753 applied += block.len() as u32;
9754 self.progress
9755 .update(|p| p.rows_processed = u64::from(applied));
9756 std::thread::yield_now();
9757 }
9758 self.progress
9759 .update(|p| p.rows_processed = u64::from(applied));
9760 Ok(applied)
9761 }
9762
9763 fn apply_sqlite_segment(
9771 &mut self,
9772 sub_index: usize,
9773 bytes: &[u8],
9774 first_fresh_page: bool,
9775 row_count: i64,
9776 as_of_commit_seq: i64,
9777 scope_digest: &str,
9778 ) -> Result<u32, SectionError> {
9779 let invalid = |detail: &str| {
9780 SectionError::Abort(
9781 "sync.invalid_request".to_owned(),
9782 format!("sqlite segment rejected: {detail} (§5.3)"),
9783 )
9784 };
9785 let (sub_table, effective) = {
9786 let sub = &self.subs[sub_index];
9787 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9788 };
9789 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
9790 SectionError::Abort(
9791 "sync.schema_mismatch".to_owned(),
9792 format!("subscription table {sub_table:?} is not in the client schema"),
9793 )
9794 })?;
9795
9796 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
9797 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
9798 let img = match rusqlite::Connection::open_with_flags(
9799 &path,
9800 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
9801 ) {
9802 Ok(conn) => conn,
9803 Err(_) => {
9804 let _ = std::fs::remove_file(&path);
9805 return Err(invalid("bytes do not open as a SQLite database"));
9806 }
9807 };
9808 let outcome = self.apply_sqlite_image(
9809 &img,
9810 &table,
9811 first_fresh_page,
9812 &effective,
9813 row_count,
9814 as_of_commit_seq,
9815 scope_digest,
9816 );
9817 drop(img);
9818 let _ = std::fs::remove_file(&path);
9819 outcome
9820 }
9821
9822 #[allow(clippy::too_many_arguments)]
9823 fn apply_sqlite_image(
9824 &mut self,
9825 img: &rusqlite::Connection,
9826 table: &crate::schema::TableSchema,
9827 first_fresh_page: bool,
9828 effective: &[(String, Vec<String>)],
9829 row_count: i64,
9830 as_of_commit_seq: i64,
9831 scope_digest: &str,
9832 ) -> Result<u32, SectionError> {
9833 let invalid = |detail: String| {
9834 SectionError::Abort(
9835 "sync.invalid_request".to_owned(),
9836 format!("sqlite segment rejected: {detail} (§5.3)"),
9837 )
9838 };
9839
9840 type MetaRow = (i64, String, i64, i64, String, i64, i64);
9843 let meta: MetaRow = img
9844 .query_row(
9845 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
9846 \"scopeDigest\", \"rowCount\",
9847 (SELECT count(*) FROM _syncular_segment)
9848 FROM _syncular_segment",
9849 [],
9850 |row| {
9851 Ok((
9852 row.get(0)?,
9853 row.get(1)?,
9854 row.get(2)?,
9855 row.get(3)?,
9856 row.get(4)?,
9857 row.get(5)?,
9858 row.get(6)?,
9859 ))
9860 },
9861 )
9862 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
9863 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
9864 if meta_count != 1 {
9865 return Err(invalid(format!(
9866 "_syncular_segment must contain exactly one row, found {meta_count}"
9867 )));
9868 }
9869 if format != 1 {
9870 return Err(invalid(format!("format {format}")));
9871 }
9872 if meta_table != table.name {
9873 return Err(invalid(format!("image table {meta_table:?}")));
9874 }
9875 if schema_version != i64::from(self.schema.version) {
9876 return Err(invalid(format!("schemaVersion {schema_version}")));
9877 }
9878 if pin != as_of_commit_seq {
9879 return Err(invalid(format!("asOfCommitSeq {pin}")));
9880 }
9881 if digest != scope_digest {
9882 return Err(invalid("scopeDigest mismatch".to_owned()));
9883 }
9884 if meta_rows != row_count {
9885 return Err(invalid(format!("rowCount {meta_rows}")));
9886 }
9887
9888 let mut names: Vec<String> = Vec::new();
9890 let mut primary_keys: Vec<String> = Vec::new();
9891 {
9892 let mut stmt = img
9893 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
9894 .map_err(|_| invalid("image data table missing".to_owned()))?;
9895 let mut rows = stmt
9896 .query([])
9897 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
9898 while let Some(row) = rows
9899 .next()
9900 .map_err(|_| invalid("image data table unreadable".to_owned()))?
9901 {
9902 if row.get::<_, i64>(5).map_err(|e| invalid(e.to_string()))? > 0 {
9903 primary_keys.push(row.get(1).map_err(|e| invalid(e.to_string()))?);
9904 }
9905 names.push(
9906 row.get::<_, String>(1)
9907 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
9908 );
9909 }
9910 }
9911 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
9912 expected.push("_syncular_version");
9913 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
9914 return Err(SectionError::Abort(
9915 "sync.schema_mismatch".to_owned(),
9916 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
9917 ));
9918 }
9919
9920 if primary_keys != vec![table.primary_key.clone()] {
9921 return Err(invalid(
9922 "image primary key does not match generated schema".into(),
9923 ));
9924 }
9925 let actual_count: i64 = img
9926 .query_row(
9927 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
9928 [],
9929 |row| row.get(0),
9930 )
9931 .map_err(|e| invalid(e.to_string()))?;
9932 if actual_count != row_count {
9933 return Err(invalid("image row count does not match descriptor".into()));
9934 }
9935 let insert = self.insert_row_sql(&base_table(&table.name), table);
9936 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
9937 let column_list = names
9938 .iter()
9939 .map(|n| quote_ident(n))
9940 .collect::<Vec<_>>()
9941 .join(", ");
9942 let mut stmt = img
9943 .prepare(&format!(
9944 "SELECT {column_list} FROM {} ORDER BY {}",
9945 quote_ident(&table.name),
9946 quote_ident(&table.primary_key)
9947 ))
9948 .map_err(|e| invalid(e.to_string()))?;
9949 let mut rows = stmt.query([]).map_err(|e| invalid(e.to_string()))?;
9950 let mut applied = 0u32;
9951 loop {
9952 let clear = applied == 0 && first_fresh_page;
9953 let was_dirty = self.overlay_dirty.get();
9954 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
9955 && self
9956 .outbox
9957 .iter()
9958 .flat_map(|commit| &commit.ops)
9959 .any(|op| op.table == table.name);
9960 let mirror_visible = !reconcile_pending;
9961 self.begin_observation("syncular_image_chunk")
9962 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9963 let result = (|| {
9964 self.rebuild_overlay_if_dirty();
9965 let mut changed_keys = Vec::new();
9966 let mut batch = ChangeAccumulator::default();
9967 if clear {
9968 if self.scoped_rows_exist(&table.name, effective) {
9969 batch.table(&table.name);
9970 }
9971 self.purge_scope_rows(&table.name, effective)
9972 .map_err(|()| SectionError::FailClosed)?;
9973 }
9974 let mut processed = 0u32;
9975 {
9976 let mut ins = self
9977 .conn
9978 .prepare_cached(&insert)
9979 .map_err(|e| invalid(e.to_string()))?;
9980 let mut mirror = if mirror_visible {
9981 Some(
9982 self.conn
9983 .prepare_cached(&visible_insert)
9984 .map_err(|e| invalid(e.to_string()))?,
9985 )
9986 } else {
9987 None
9988 };
9989 for _ in 0..1024 {
9990 let Some(row) = rows.next().map_err(|e| invalid(e.to_string()))? else {
9991 break;
9992 };
9993 for (i, column) in table.columns.iter().enumerate() {
9994 let cell = row.get_ref(i).map_err(|e| invalid(e.to_string()))?;
9995 let param = image_cell_param(column, cell).map_err(&invalid)?;
9996 ins.raw_bind_parameter(i + 1, ¶m)
9997 .map_err(|e| invalid(e.to_string()))?;
9998 if let Some(mirror) = &mut mirror {
9999 mirror
10000 .raw_bind_parameter(i + 1, ¶m)
10001 .map_err(|e| invalid(e.to_string()))?;
10002 }
10003 }
10004 let version_index = table.columns.len();
10005 let version: i64 =
10006 row.get(version_index).map_err(|e| invalid(e.to_string()))?;
10007 if version < 1 {
10008 return Err(invalid("image row version must be positive".into()));
10009 }
10010 ins.raw_bind_parameter(version_index + 1, version)
10011 .map_err(|e| invalid(e.to_string()))?;
10012 ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
10013 if let Some(mirror) = &mut mirror {
10014 mirror
10015 .raw_bind_parameter(version_index + 1, version)
10016 .map_err(|e| invalid(e.to_string()))?;
10017 mirror.raw_execute().map_err(|e| invalid(e.to_string()))?;
10018 }
10019 if reconcile_pending {
10020 changed_keys.push(
10021 row.get::<_, SqlValue>(table.pk_index)
10022 .map_err(|error| invalid(error.to_string()))?,
10023 );
10024 }
10025 processed += 1;
10026 }
10027 }
10028 if processed > 0 {
10029 batch.table(&table.name);
10030 self.overlay_dirty.set(true);
10031 }
10032 if reconcile_pending {
10033 self.reconcile_imported_rows(table, &mut changed_keys)
10034 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10035 }
10036 self.apply_outbox_ops(
10037 self.outbox
10038 .iter()
10039 .flat_map(|commit| &commit.ops)
10040 .filter(|op| op.table == table.name),
10041 );
10042 self.overlay_dirty.set(false);
10043 self.finish_observation("syncular_image_chunk", batch)
10044 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10045 Ok(processed)
10046 })();
10047 let processed = match result {
10048 Ok(processed) => processed,
10049 Err(error) => {
10050 self.rollback_observation("syncular_image_chunk");
10051 self.overlay_dirty.set(was_dirty);
10052 return Err(error);
10053 }
10054 };
10055 applied += processed;
10056 self.progress
10057 .update(|p| p.rows_processed = u64::from(applied));
10058 std::thread::yield_now();
10059 if i64::from(applied) == actual_count {
10060 break;
10061 }
10062 }
10063 Ok(applied)
10064 }
10065
10066 fn reconcile_imported_rows(
10070 &self,
10071 table: &TableSchema,
10072 keys: &mut Vec<SqlValue>,
10073 ) -> Result<(), String> {
10074 let visible = visible_table(&table.name);
10075 let base = base_table(&table.name);
10076 let pk = quote_ident(&table.primary_key);
10077 let predicate = row_id_predicate(table);
10078 let mut lookup = self.conn.prepare_cached(&format!(
10079 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
10080 )).map_err(|error| error.to_string())?;
10081 let mut deleted = HashSet::new();
10082 for op in self
10083 .outbox
10084 .iter()
10085 .flat_map(|commit| &commit.ops)
10086 .filter(|op| op.table == table.name)
10087 {
10088 if op.upsert {
10089 if let Some(values) = &op.values {
10090 let key = json_to_column_value(
10091 &table.columns[table.pk_index],
10092 values.get(&table.primary_key),
10093 )?;
10094 keys.push(owned_sql_value(RowParam::Cell(&key))?);
10095 }
10096 } else if deleted.insert(&op.row_id) {
10097 keys.extend(
10098 lookup
10099 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
10100 .map_err(|error| error.to_string())?
10101 .collect::<Result<Vec<_>, _>>()
10102 .map_err(|error| error.to_string())?,
10103 );
10104 }
10105 }
10106 for chunk in keys.chunks(400) {
10109 let holes = vec!["?"; chunk.len()].join(",");
10110 self.conn
10111 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
10112 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10113 .map_err(|error| error.to_string())?;
10114 }
10115 let updates = table
10116 .columns
10117 .iter()
10118 .map(|column| column.name.as_str())
10119 .chain(std::iter::once("_syncular_version"))
10120 .filter(|name| *name != table.primary_key)
10121 .map(|name| {
10122 let name = quote_ident(name);
10123 format!("{name}=excluded.{name}")
10124 })
10125 .collect::<Vec<_>>()
10126 .join(",");
10127 for chunk in keys.chunks(400) {
10128 let holes = vec!["?"; chunk.len()].join(",");
10129 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
10130 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10131 .map_err(|error| error.to_string())?;
10132 }
10133 Ok(())
10134 }
10135
10136 fn compile_local_data_purge(
10139 &self,
10140 input: &LocalDataPurgeInput,
10141 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
10142 let invalid = |message: String| format!("sync.invalid_request: {message}");
10143 if input.purge_id.is_empty()
10144 || input.purge_id.len() > 128
10145 || !is_local_operation_code_like(&input.purge_id)
10146 {
10147 return Err(invalid(
10148 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
10149 ));
10150 }
10151 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
10152 return Err(invalid(format!(
10153 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
10154 )));
10155 }
10156
10157 let mut deduplicated: BTreeMap<
10158 String,
10159 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
10160 > = BTreeMap::new();
10161 for target in &input.targets {
10162 let table = self.schema.table(&target.table).ok_or_else(|| {
10163 invalid(format!(
10164 "local purge names unknown table {:?}",
10165 target.table
10166 ))
10167 })?;
10168 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
10169 return Err(invalid(format!(
10170 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
10171 target.table
10172 )));
10173 }
10174 let mut selectors = Vec::with_capacity(target.selectors.len());
10175 let mut canonical_selectors = BTreeMap::new();
10176 for (column_name, raw_values) in &target.selectors {
10177 let Some((column_index, column)) = table
10178 .columns
10179 .iter()
10180 .enumerate()
10181 .find(|(_, column)| column.name == *column_name)
10182 else {
10183 return Err(invalid(format!(
10184 "local purge target {:?} names unknown column {:?}",
10185 target.table, column_name
10186 )));
10187 };
10188 let encrypted = table
10189 .encrypted_columns
10190 .iter()
10191 .any(|candidate| candidate.index == column_index);
10192 if column.ty != ColumnType::String || encrypted {
10193 return Err(invalid(format!(
10194 "local purge selector {:?}.{:?} must be a plaintext string column",
10195 target.table, column_name
10196 )));
10197 }
10198 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
10199 return Err(invalid(format!(
10200 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
10201 target.table, column_name
10202 )));
10203 }
10204 let mut values = raw_values.clone();
10205 values.sort();
10206 values.dedup();
10207 if values.iter().any(|value| {
10208 value.is_empty()
10209 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
10210 || !is_local_operation_code_like(value)
10211 }) {
10212 return Err(invalid(format!(
10213 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
10214 )));
10215 }
10216 selectors.push((column_name.clone(), values.clone()));
10217 canonical_selectors.insert(column_name.clone(), values);
10218 }
10219 let canonical = LocalDataPurgeTarget {
10220 table: target.table.clone(),
10221 selectors: canonical_selectors,
10222 };
10223 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10224 deduplicated.insert(
10225 key,
10226 (
10227 CompiledLocalDataPurgeTarget {
10228 table: target.table.clone(),
10229 selectors,
10230 },
10231 canonical,
10232 ),
10233 );
10234 }
10235 let targets = deduplicated
10236 .values()
10237 .map(|(compiled, _)| compiled.clone())
10238 .collect::<Vec<_>>();
10239 let canonical = deduplicated
10240 .values()
10241 .map(|(_, target)| target.clone())
10242 .collect::<Vec<_>>();
10243 let canonical_plan =
10244 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10245 Ok((targets, canonical_plan))
10246 }
10247
10248 fn local_purge_base_row_ids(
10249 &self,
10250 targets: &[CompiledLocalDataPurgeTarget],
10251 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
10252 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10253 for target in targets {
10254 let table = self
10255 .schema
10256 .table(&target.table)
10257 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
10258 let mut clauses = Vec::with_capacity(target.selectors.len());
10259 let mut params = Vec::new();
10260 for (column, values) in &target.selectors {
10261 clauses.push(format!(
10262 "{} IN ({})",
10263 quote_ident(column),
10264 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
10265 ));
10266 params.extend(values.iter().cloned().map(SqlValue::Text));
10267 }
10268 let sql = format!(
10269 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
10270 quote_ident(&table.primary_key),
10271 base_table(&target.table),
10272 clauses.join(" AND ")
10273 );
10274 let mut statement = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
10275 let rows = statement
10276 .query_map(rusqlite::params_from_iter(params), |row| {
10277 row.get::<_, String>(0)
10278 })
10279 .map_err(|error| error.to_string())?;
10280 let ids = by_table.entry(target.table.clone()).or_default();
10281 for row in rows {
10282 ids.insert(row.map_err(|error| error.to_string())?);
10283 }
10284 }
10285 Ok(by_table)
10286 }
10287
10288 fn local_purge_values_match(
10289 target: &CompiledLocalDataPurgeTarget,
10290 values: &Map<String, Value>,
10291 ) -> bool {
10292 target.selectors.iter().all(|(column, allowed)| {
10293 values
10294 .get(column)
10295 .and_then(Value::as_str)
10296 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
10297 })
10298 }
10299
10300 pub fn purge_local_data(
10304 &mut self,
10305 input: &LocalDataPurgeInput,
10306 ) -> Result<LocalDataPurgeResult, String> {
10307 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
10308 let meta_key = format!("localPurge:{}", input.purge_id);
10309 let applied_plan = self
10310 .conn
10311 .query_row(
10312 "SELECT value FROM _syncular_meta WHERE key = ?1",
10313 rusqlite::params![meta_key],
10314 |row| row.get::<_, String>(0),
10315 )
10316 .optional()
10317 .map_err(|error| error.to_string())?;
10318 if let Some(applied_plan) = applied_plan {
10319 if applied_plan != canonical_plan {
10320 return Err(format!(
10321 "sync.invalid_request: local purge id {:?} was already used with a different plan",
10322 input.purge_id
10323 ));
10324 }
10325 return Ok(LocalDataPurgeResult {
10326 already_applied: true,
10327 purged_rows: 0,
10328 dropped_commits: 0,
10329 });
10330 }
10331
10332 let prior_outbox = self.outbox.clone();
10333 let prior_rejection_count = self.rejections.len();
10334 let prior_overlay_dirty = self.overlay_dirty.get();
10335 self.begin_observation("syncular_local_purge")?;
10336 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
10337 let row_ids = self.local_purge_base_row_ids(&targets)?;
10338 let doomed = self
10339 .outbox
10340 .iter()
10341 .filter(|commit| {
10342 commit.ops.iter().any(|operation| {
10343 let matching_targets = targets
10344 .iter()
10345 .filter(|target| target.table == operation.table)
10346 .collect::<Vec<_>>();
10347 if matching_targets.is_empty() {
10348 return false;
10349 }
10350 if row_ids
10351 .get(&operation.table)
10352 .is_some_and(|ids| ids.contains(&operation.row_id))
10353 {
10354 return true;
10355 }
10356 operation.values.as_ref().is_some_and(|values| {
10357 matching_targets
10358 .iter()
10359 .any(|target| Self::local_purge_values_match(target, values))
10360 })
10361 })
10362 })
10363 .cloned()
10364 .collect::<Vec<_>>();
10365 let doomed_ids = doomed
10366 .iter()
10367 .map(|commit| commit.client_commit_id.clone())
10368 .collect::<BTreeSet<_>>();
10369 let mut batch = ChangeAccumulator::default();
10370 let mut rejections = Vec::new();
10371 for commit in &doomed {
10372 for operation in &commit.ops {
10373 batch.table(&operation.table);
10374 }
10375 let results = commit
10376 .ops
10377 .iter()
10378 .enumerate()
10379 .map(|(op_index, operation)| {
10380 let rejection = RejectionRecord {
10381 client_commit_id: commit.client_commit_id.clone(),
10382 op_index: op_index as i32,
10383 code: "client.local_data_purged".to_owned(),
10384 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
10385 retryable: false,
10386 details: None,
10387 operation: Some(CommitOperation::from(operation)),
10388 };
10389 rejections.push(rejection.clone());
10390 CommitOperationOutcome::Error { rejection }
10391 })
10392 .collect::<Vec<_>>();
10393 self.persist_commit_outcome(
10394 &commit.client_commit_id,
10395 CommitOutcomeStatus::Rejected,
10396 &results,
10397 Some(&commit.ops),
10398 )?;
10399 self.delete_outbox_persisted(&commit.client_commit_id)?;
10400 }
10401 if !doomed.is_empty() {
10402 self.outbox
10403 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
10404 self.rejections.extend(rejections);
10405 self.prune_commit_outcomes()?;
10406 self.overlay_dirty.set(true);
10407 batch.status = true;
10408 batch.rejections = true;
10409 batch.outcomes = true;
10410 }
10411
10412 let mut purged_rows = 0usize;
10413 for (table, ids) in &row_ids {
10414 if ids.is_empty() {
10415 continue;
10416 }
10417 batch.table(table);
10418 for row_id in ids {
10419 self.delete_base_row(table, row_id)?;
10420 purged_rows += 1;
10421 }
10422 }
10423 self.rebuild_overlay_if_dirty();
10424 self.delete_unreferenced_cached_blobs();
10425 self.conn
10426 .execute(
10427 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10428 rusqlite::params![meta_key, canonical_plan],
10429 )
10430 .map_err(|error| error.to_string())?;
10431 Ok((
10432 batch,
10433 LocalDataPurgeResult {
10434 already_applied: false,
10435 purged_rows,
10436 dropped_commits: doomed.len(),
10437 },
10438 ))
10439 })();
10440 let (batch, result) = match applied {
10441 Ok(value) => value,
10442 Err(error) => {
10443 self.rollback_observation("syncular_local_purge");
10444 self.outbox = prior_outbox;
10445 self.rejections.truncate(prior_rejection_count);
10446 self.overlay_dirty.set(prior_overlay_dirty);
10447 return Err(error);
10448 }
10449 };
10450 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
10451 self.rollback_observation("syncular_local_purge");
10452 self.outbox = prior_outbox;
10453 self.rejections.truncate(prior_rejection_count);
10454 self.overlay_dirty.set(prior_overlay_dirty);
10455 return Err(error);
10456 }
10457 Ok(result)
10458 }
10459
10460 pub fn rebootstrap_local_data(
10466 &mut self,
10467 input: &LocalDataRebootstrapInput,
10468 ) -> Result<LocalDataRebootstrapResult, String> {
10469 if input.rebootstrap_id.is_empty()
10470 || input.rebootstrap_id.len() > 128
10471 || !is_local_operation_code_like(&input.rebootstrap_id)
10472 {
10473 return Err(
10474 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
10475 .to_owned(),
10476 );
10477 }
10478 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
10479 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
10480 let (retained_commits, reset_subscriptions) =
10481 decode_local_rebootstrap_receipt(&persisted)?;
10482 return Ok(LocalDataRebootstrapResult {
10483 already_applied: true,
10484 retained_commits,
10485 reset_subscriptions,
10486 });
10487 }
10488 if self.stopped || self.schema_floor.is_some() {
10489 return Err(
10490 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
10491 .to_owned(),
10492 );
10493 }
10494
10495 let retained_commits = self.outbox.len();
10496 let reset_subscriptions = self.subs.len();
10497 let prior_subs = self.subs.clone();
10498 let prior_upgrading = self.upgrading;
10499 let prior_stopped = self.stopped;
10500 let prior_schema_floor = self.schema_floor.clone();
10501 let prior_overlay_dirty = self.overlay_dirty.get();
10502 let prior_sync_needed = self.sync_needed;
10503 let prior_sync_intents = self.sync_intent_queue.clone();
10504 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
10505
10506 self.begin_observation("syncular_local_rebootstrap")?;
10507 let mut batch = ChangeAccumulator::default();
10508 let applied = (|| -> Result<(), String> {
10509 self.run_schema_reset_observed(&mut batch, false)?;
10510 self.conn
10511 .execute(
10512 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10513 rusqlite::params![meta_key, receipt],
10514 )
10515 .map_err(|error| error.to_string())?;
10516 self.sync_needed = true;
10517 self.sync_intent_queue.push_back(SyncIntent::Interactive);
10518 batch.status = true;
10519 Ok(())
10520 })();
10521 if let Err(error) = applied {
10522 self.rollback_observation("syncular_local_rebootstrap");
10523 self.subs = prior_subs;
10524 self.upgrading = prior_upgrading;
10525 self.stopped = prior_stopped;
10526 self.schema_floor = prior_schema_floor;
10527 self.overlay_dirty.set(prior_overlay_dirty);
10528 self.sync_needed = prior_sync_needed;
10529 self.sync_intent_queue = prior_sync_intents;
10530 return Err(error);
10531 }
10532 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
10533 self.rollback_observation("syncular_local_rebootstrap");
10534 self.subs = prior_subs;
10535 self.upgrading = prior_upgrading;
10536 self.stopped = prior_stopped;
10537 self.schema_floor = prior_schema_floor;
10538 self.overlay_dirty.set(prior_overlay_dirty);
10539 self.sync_needed = prior_sync_needed;
10540 self.sync_intent_queue = prior_sync_intents;
10541 return Err(error);
10542 }
10543
10544 Ok(LocalDataRebootstrapResult {
10545 already_applied: false,
10546 retained_commits,
10547 reset_subscriptions,
10548 })
10549 }
10550
10551 fn purge_scope_rows(
10556 &mut self,
10557 table_name: &str,
10558 effective: &[(String, Vec<String>)],
10559 ) -> Result<(), ()> {
10560 if effective.is_empty() {
10561 return Ok(());
10562 }
10563 let table = self.schema.table(table_name).ok_or(())?.clone();
10564 let mut clauses = Vec::new();
10565 let mut params: Vec<SqlValue> = Vec::new();
10566 for (variable, values) in effective {
10567 let column = table.scope_column(variable).ok_or(())?;
10568 let placeholders: Vec<String> = values
10569 .iter()
10570 .map(|v| {
10571 params.push(SqlValue::Text(v.clone()));
10572 "?".to_owned()
10573 })
10574 .collect();
10575 clauses.push(format!(
10576 "{} IN ({})",
10577 quote_ident(column),
10578 placeholders.join(", ")
10579 ));
10580 }
10581 let sql = format!(
10582 "DELETE FROM {} WHERE {}",
10583 base_table(table_name),
10584 clauses.join(" AND ")
10585 );
10586 self.overlay_dirty.set(true);
10587 self.conn
10588 .execute(&sql, rusqlite::params_from_iter(¶ms))
10589 .map_err(|_| ())?;
10590 self.conn
10591 .execute(
10592 &format!(
10593 "DELETE FROM {} WHERE {}",
10594 visible_table(table_name),
10595 clauses.join(" AND ")
10596 ),
10597 rusqlite::params_from_iter(¶ms),
10598 )
10599 .map_err(|_| ())?;
10600 Ok(())
10601 }
10602
10603 fn drop_doomed_outbox(
10606 &mut self,
10607 table_name: &str,
10608 effective: &[(String, Vec<String>)],
10609 ) -> Result<bool, String> {
10610 if effective.is_empty() {
10611 return Ok(false);
10612 }
10613 let Some(table) = self.schema.table(table_name).cloned() else {
10614 return Ok(false);
10615 };
10616 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
10617 for (variable, values) in effective {
10618 match table.scope_column(variable) {
10619 Some(column) => mappings.push((column, values)),
10620 None => return Ok(false), }
10622 }
10623 let doomed: Vec<OutboxCommit> = self
10624 .outbox
10625 .iter()
10626 .filter(|commit| {
10627 commit.ops.iter().any(|op| {
10628 op.upsert
10629 && op.table == table_name
10630 && op.values.as_ref().is_some_and(|values| {
10631 mappings
10632 .iter()
10633 .all(|(column, allowed)| match values.get(*column) {
10634 Some(Value::String(s)) => allowed.contains(s),
10635 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
10636 _ => false,
10637 })
10638 })
10639 })
10640 })
10641 .cloned()
10642 .collect();
10643 if doomed.is_empty() {
10644 return Ok(false);
10645 }
10646 let mut rejections = Vec::new();
10647 for commit in &doomed {
10648 let results = commit
10649 .ops
10650 .iter()
10651 .enumerate()
10652 .map(|(op_index, operation)| {
10653 let rejection = RejectionRecord {
10654 client_commit_id: commit.client_commit_id.clone(),
10655 op_index: op_index as i32,
10656 code: "sync.scope_revoked".to_owned(),
10657 message: "the commit was dropped because its effective scope was revoked"
10658 .to_owned(),
10659 retryable: false,
10660 details: None,
10661 operation: Some(CommitOperation::from(operation)),
10662 };
10663 rejections.push(rejection.clone());
10664 CommitOperationOutcome::Error { rejection }
10665 })
10666 .collect::<Vec<_>>();
10667 self.persist_commit_outcome(
10668 &commit.client_commit_id,
10669 CommitOutcomeStatus::Rejected,
10670 &results,
10671 Some(&commit.ops),
10672 )?;
10673 self.delete_outbox_persisted(&commit.client_commit_id)?;
10674 }
10675 self.prune_commit_outcomes()?;
10676 let doomed_ids = doomed
10677 .iter()
10678 .map(|commit| commit.client_commit_id.as_str())
10679 .collect::<BTreeSet<_>>();
10680 self.outbox
10681 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
10682 self.rejections.extend(rejections);
10683 self.overlay_dirty.set(true);
10684 Ok(true)
10685 }
10686
10687 pub fn upload_blob(
10694 &mut self,
10695 bytes: &[u8],
10696 media_type: Option<String>,
10697 name: Option<String>,
10698 ) -> Result<Value, String> {
10699 let blob_id = blob_id_for(bytes);
10700 let now = self.clock_now_ms();
10701 let tx = self.conn.transaction().map_err(|e| e.to_string())?;
10702 tx.execute(
10703 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
10704 ON CONFLICT(blob_id) DO NOTHING",
10705 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
10706 )
10707 .map_err(|e| e.to_string())?;
10708 tx.execute(
10709 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
10710 rusqlite::params![blob_id, media_type, now],
10711 )
10712 .map_err(|e| e.to_string())?;
10713 tx.commit().map_err(|e| e.to_string())?;
10714 self.enforce_blob_cache_cap();
10715 let mut obj = Map::new();
10716 obj.insert("blobId".to_owned(), Value::from(blob_id));
10717 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
10718 if let Some(mt) = media_type {
10719 obj.insert("mediaType".to_owned(), Value::from(mt));
10720 }
10721 if let Some(n) = name {
10722 obj.insert("name".to_owned(), Value::from(n));
10723 }
10724 Ok(Value::Object(obj))
10725 }
10726
10727 pub fn fetch_blob_bytes(
10731 &mut self,
10732 transport: &mut dyn Transport,
10733 blob_id_or_ref: &str,
10734 ) -> Result<FetchedBlob, (String, String)> {
10735 let simple = |m: String| ("client.failed".to_owned(), m);
10736 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
10737 blob_id_or_ref.to_owned()
10738 } else {
10739 let value: Value = serde_json::from_str(blob_id_or_ref)
10740 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
10741 value
10742 .get("blobId")
10743 .and_then(Value::as_str)
10744 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
10745 .to_owned()
10746 };
10747 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
10748 return Ok(cached);
10749 }
10750 #[cfg(feature = "bench-internals")]
10756 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
10757 let bytes = match transport
10758 .blob_download(&blob_id)
10759 .map_err(|e| (e.code, e.message))?
10760 {
10761 BlobDownload::Bytes(bytes) => bytes,
10762 BlobDownload::Url {
10763 url,
10764 url_expires_at_ms,
10765 } => {
10766 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
10768 return Err((
10769 "sync.segment_expired".to_owned(),
10770 format!(
10771 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
10772 ),
10773 ));
10774 }
10775 transport
10776 .fetch_blob_url(&url)
10777 .map_err(|e| (e.code, e.message))?
10778 }
10779 };
10780 #[cfg(feature = "bench-internals")]
10781 drop(download_phase);
10782 #[cfg(feature = "bench-internals")]
10783 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
10784 if blob_id_for(&bytes) != blob_id {
10786 return Err(simple(format!(
10787 "blob content address mismatch for {blob_id}"
10788 )));
10789 }
10790 #[cfg(feature = "bench-internals")]
10791 drop(validate_phase);
10792 #[cfg(feature = "bench-internals")]
10793 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
10794 let now = self.clock_now_ms();
10795 self.conn
10796 .execute(
10797 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
10798 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
10799 )
10800 .map_err(|e| simple(e.to_string()))?;
10801 self.conn
10802 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
10803 .map_err(|e| simple(e.to_string()))?;
10804 self.enforce_blob_cache_cap();
10805 #[cfg(feature = "bench-internals")]
10806 drop(insert_phase);
10807 self.get_cached_blob_bytes(&blob_id)
10808 .map_err(simple)?
10809 .ok_or_else(|| simple("blob cache write failed".to_owned()))
10810 }
10811
10812 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
10813 #[cfg(feature = "bench-internals")]
10814 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
10815 let mut stmt = self
10816 .conn
10817 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
10818 .map_err(|e| e.to_string())?;
10819 let mut rows = stmt
10820 .query(rusqlite::params![blob_id])
10821 .map_err(|e| e.to_string())?;
10822 if let Some(row) = rows.next().map_err(|e| e.to_string())? {
10823 let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
10824 let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
10825 let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
10826 return Ok(Some(FetchedBlob {
10827 blob_id: blob_id.to_owned(),
10828 byte_length,
10829 bytes,
10830 media_type,
10831 }));
10832 }
10833 Ok(None)
10834 }
10835
10836 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
10838 let pending: Vec<(String, Option<String>)> = {
10839 let mut stmt = self
10840 .conn
10841 .prepare(
10842 "SELECT blob_id, media_type FROM (
10843 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
10844 UNION ALL
10845 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
10846 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
10847 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
10848 ) ORDER BY created_at_ms, blob_id",
10849 )
10850 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10851 let rows = stmt
10852 .query_map([], |row| {
10853 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
10854 })
10855 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10856 rows.collect::<Result<_, _>>()
10857 .map_err(|error| match error {
10858 rusqlite::Error::InvalidColumnType(..)
10859 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
10860 "sync.local_corrupt",
10861 "Pending blob upload metadata is invalid",
10862 ),
10863 _ => TransportError::new("client.failed", error.to_string()),
10864 })?
10865 };
10866 for (blob_id, media_type) in pending {
10867 let stored: Option<(SqlValue, SqlValue)> = self
10868 .conn
10869 .query_row(
10870 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
10871 rusqlite::params![blob_id],
10872 |row| Ok((row.get(0)?, row.get(1)?)),
10873 )
10874 .optional()
10875 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10876 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
10877 return Err(TransportError::new(
10878 "sync.local_corrupt",
10879 "Pending blob upload body is missing or corrupt",
10880 ));
10881 };
10882 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
10883 return Err(TransportError::new(
10884 "sync.local_corrupt",
10885 "Pending blob upload body is missing or corrupt",
10886 ));
10887 }
10888 self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
10889 self.conn
10890 .execute(
10891 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
10892 rusqlite::params![blob_id],
10893 )
10894 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10895 }
10896 Ok(())
10897 }
10898
10899 fn upload_one(
10906 &self,
10907 transport: &mut dyn Transport,
10908 blob_id: &str,
10909 bytes: &[u8],
10910 media_type: Option<&str>,
10911 ) -> Result<(), TransportError> {
10912 match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
10913 BlobUploadGrant::Present => return Ok(()), BlobUploadGrant::Url {
10915 url,
10916 url_expires_at_ms,
10917 } => {
10918 let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
10919 if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
10920 return Ok(());
10921 }
10922 }
10924 BlobUploadGrant::None => {
10925 }
10927 }
10928 transport.blob_upload(blob_id, bytes, media_type)
10929 }
10930
10931 fn visible_blob_ids_sql(&self) -> String {
10932 let mut selects = Vec::new();
10933 for table in &self.schema.tables {
10934 for column in table
10935 .columns
10936 .iter()
10937 .filter(|column| column.ty == ColumnType::BlobRef)
10938 {
10939 let identifier = quote_ident(&column.name);
10940 selects.push(format!(
10941 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
10942 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
10943 AND json_type({identifier}, '$.blobId') = 'text'",
10944 quote_ident(&table.name),
10945 ));
10946 }
10947 }
10948 if selects.is_empty() {
10949 "SELECT NULL AS blob_id WHERE 0".to_owned()
10950 } else {
10951 selects.join(" UNION ")
10952 }
10953 }
10954
10955 fn enforce_blob_cache_cap(&self) {
10958 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
10959 return;
10960 };
10961 let mut total: i64 = self
10962 .conn
10963 .query_row(
10964 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
10965 [],
10966 |row| row.get(0),
10967 )
10968 .unwrap_or(0);
10969 if total <= max_bytes {
10970 return;
10971 }
10972 let query = format!(
10973 "SELECT blob_id, byte_length FROM _syncular_blobs
10974 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
10975 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
10976 AND blob_id NOT IN ({})
10977 ORDER BY created_at_ms ASC, blob_id ASC",
10978 self.visible_blob_ids_sql(),
10979 );
10980 let candidates: Vec<(String, i64)> = {
10981 let Ok(mut stmt) = self.conn.prepare(&query) else {
10982 return;
10983 };
10984 let Ok(rows) = stmt.query_map([], |row| {
10985 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
10986 }) else {
10987 return;
10988 };
10989 rows.filter_map(Result::ok).collect()
10990 };
10991 for (blob_id, byte_length) in candidates {
10992 if total <= max_bytes {
10993 break;
10994 }
10995 let _ = self.conn.execute(
10996 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
10997 rusqlite::params![blob_id],
10998 );
10999 total -= byte_length;
11000 }
11001 }
11002
11003 fn delete_unreferenced_cached_blobs(&mut self) {
11004 #[cfg(feature = "bench-internals")]
11005 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
11006 if !self.schema_has_blobs() {
11007 return;
11008 }
11009 self.rebuild_overlay_if_dirty();
11010 let _ = self.conn.execute(
11011 &format!(
11012 "DELETE FROM _syncular_blobs
11013 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11014 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11015 AND blob_id NOT IN ({})",
11016 self.visible_blob_ids_sql(),
11017 ),
11018 [],
11019 );
11020 }
11021
11022 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
11027 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
11028 return sql.clone();
11029 }
11030 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
11031 columns.push(quote_ident("_syncular_version"));
11032 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
11033 let primary_key = quote_ident(&table.primary_key);
11034 let updates = columns
11035 .iter()
11036 .filter(|column| **column != primary_key)
11037 .map(|column| format!("{column}=excluded.{column}"))
11038 .collect::<Vec<_>>()
11039 .join(", ");
11040 let sql = format!(
11041 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
11042 columns.join(", "),
11043 placeholders.join(", ")
11044 );
11045 self.insert_sql
11046 .borrow_mut()
11047 .insert(full_table.to_owned(), sql.clone());
11048 sql
11049 }
11050
11051 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
11052 self.overlay_dirty.set(true);
11053 self.write_row(&base_table(table_name), table_name, row, version)
11054 }
11055
11056 fn write_row(
11057 &self,
11058 full_table: &str,
11059 table_name: &str,
11060 row: &Row,
11061 version: i64,
11062 ) -> Result<(), String> {
11063 #[cfg(feature = "bench-internals")]
11064 let _phase = self.benchmark_phases.start(Phase::RowWrite);
11065 let table = self
11066 .schema
11067 .table(table_name)
11068 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11069 let sql = self.insert_row_sql(full_table, table);
11070 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11071 let params = row
11072 .iter()
11073 .map(RowParam::Cell)
11074 .chain(std::iter::once(RowParam::Version(version)));
11075 stmt.execute(rusqlite::params_from_iter(params))
11076 .map_err(|e| e.to_string())?;
11077 Ok(())
11078 }
11079
11080 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
11081 let table = self
11082 .schema
11083 .table(table_name)
11084 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11085 self.overlay_dirty.set(true);
11086 let sql = format!(
11087 "DELETE FROM {} WHERE {}",
11088 base_table(table_name),
11089 row_id_predicate(table)
11090 );
11091 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11092 stmt.execute(rusqlite::params![row_id])
11093 .map_err(|e| e.to_string())?;
11094 Ok(())
11095 }
11096
11097 fn rebuild_overlay_if_dirty(&mut self) {
11100 if self.overlay_dirty.get() {
11101 self.rebuild_overlay();
11102 }
11103 }
11104
11105 fn rebuild_overlay(&mut self) {
11109 #[cfg(feature = "bench-internals")]
11110 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
11111 #[cfg(test)]
11112 self.overlay_rebuild_count
11113 .set(self.overlay_rebuild_count.get() + 1);
11114 self.exec("SAVEPOINT syncular_overlay");
11115 for table in self.schema.tables.clone() {
11116 for index in &table.fts_indexes {
11117 let _ = self.drop_fts_triggers(index);
11118 }
11119 let visible = visible_table(&table.name);
11120 let base = base_table(&table.name);
11121 self.exec(&format!("DELETE FROM {visible}"));
11122 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
11123 }
11124 for commit in &self.outbox {
11125 self.apply_outbox_ops(&commit.ops);
11126 }
11127 for table in self.schema.tables.clone() {
11128 for index in &table.fts_indexes {
11129 let _ = self.rebuild_fts_projection(&table, index);
11130 let _ = self.create_fts_triggers(&table, index);
11131 }
11132 }
11133 self.exec("RELEASE syncular_overlay");
11134 self.overlay_dirty.set(false);
11135 }
11136
11137 fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
11138 #[cfg(feature = "bench-internals")]
11139 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
11140 for op in ops {
11141 #[cfg(feature = "bench-internals")]
11142 if let Some(phase) = &mut phase {
11143 phase.unit();
11144 }
11145 let Some(table) = self.schema.table(&op.table) else {
11146 continue;
11147 };
11148 if op.upsert {
11149 let Some(values) = op.values.as_ref() else {
11150 continue;
11151 };
11152 let full = table
11157 .columns
11158 .iter()
11159 .all(|column| values.contains_key(&column.name));
11160 let local = self.visible_row(table, &op.row_id);
11161 if local.is_none() && !full {
11162 continue;
11163 }
11164 let mut row: Row = Vec::with_capacity(table.columns.len());
11165 let mut ok = true;
11166 for (index, column) in table.columns.iter().enumerate() {
11167 let incoming = match values.get(&column.name) {
11168 Some(raw) => json_to_column_value(column, Some(raw)),
11169 None => Ok(local
11170 .as_ref()
11171 .and_then(|(values, _)| values.get(index).cloned())
11172 .flatten()),
11173 };
11174 match incoming {
11175 Ok(value) => row.push(value),
11176 Err(_) => {
11177 ok = false;
11178 break;
11179 }
11180 }
11181 }
11182 if ok {
11183 let version = local.as_ref().map_or(-1, |(_, version)| *version);
11184 let _ = self.write_row(&visible_table(&table.name), &table.name, &row, version);
11185 }
11186 } else {
11187 let sql = format!(
11188 "DELETE FROM {} WHERE {}",
11189 visible_table(&table.name),
11190 row_id_predicate(table)
11191 );
11192 let _ = self
11193 .conn
11194 .prepare_cached(&sql)
11195 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]));
11196 }
11197 }
11198 }
11199
11200 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
11203 let sql = format!(
11204 "SELECT * FROM {} WHERE {}",
11205 visible_table(&table.name),
11206 row_id_predicate(table)
11207 );
11208 let mut stmt = self.conn.prepare_cached(&sql).ok()?;
11209 let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
11210 let row = rows.next().ok()??;
11211 let mut values = Vec::with_capacity(table.columns.len());
11212 for (index, column) in table.columns.iter().enumerate() {
11213 let raw = row.get_ref(index).ok()?;
11214 let json = sql_ref_to_json(column, raw);
11215 values.push(json_to_column_value(column, Some(&json)).ok()?);
11216 }
11217 let version: i64 = row.get(table.columns.len()).ok()?;
11218 Some((values, version))
11219 }
11220
11221 fn exec(&self, sql: &str) {
11222 let _ = self.conn.execute_batch(sql);
11223 }
11224
11225 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
11228 if self.realtime_connected {
11229 return Ok(());
11230 }
11231 transport
11232 .realtime_connect_for_client(&self.client_id)
11233 .map_err(|e| format!("{}: {}", e.code, e.message))?;
11234 self.realtime_connected = true;
11235 Ok(())
11236 }
11237
11238 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
11239 if !self.realtime_connected {
11240 return;
11241 }
11242 let _ = transport.realtime_close();
11243 self.realtime_connected = false;
11244 self.presence.clear(); }
11246
11247 pub fn set_presence(
11253 &mut self,
11254 transport: &mut dyn Transport,
11255 scope_key: &str,
11256 doc: Option<&Value>,
11257 ) -> Result<(), String> {
11258 if !self.realtime_connected {
11259 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
11260 }
11261 let text = encode_presence_publish(scope_key, doc);
11262 transport
11263 .realtime_send(&text)
11264 .map_err(|e| format!("{}: {}", e.code, e.message))
11265 }
11266
11267 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
11269 self.presence
11270 .get(scope_key)
11271 .map(|peers| peers.values().cloned().collect())
11272 .unwrap_or_default()
11273 }
11274
11275 fn apply_presence(
11277 &mut self,
11278 scope_key: String,
11279 kind: Option<PresenceKind>,
11280 actor_id: Option<String>,
11281 client_id: Option<String>,
11282 doc: Option<Value>,
11283 error: Option<String>,
11284 ) {
11285 if error.is_some() {
11288 return;
11289 }
11290 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
11291 return;
11292 };
11293 let peer_key = format!("{actor_id} {client_id}");
11294 match kind {
11295 PresenceKind::Leave => {
11296 if let Some(peers) = self.presence.get_mut(&scope_key) {
11297 peers.remove(&peer_key);
11298 if peers.is_empty() {
11299 self.presence.remove(&scope_key);
11300 }
11301 }
11302 }
11303 _ => {
11304 let doc = match doc {
11305 Some(Value::Object(_)) => doc.unwrap(),
11306 _ => return,
11307 };
11308 self.presence.entry(scope_key).or_default().insert(
11309 peer_key,
11310 PresencePeer {
11311 actor_id,
11312 client_id,
11313 doc,
11314 },
11315 );
11316 }
11317 }
11318 }
11319
11320 pub fn on_realtime_text(&mut self, text: &str) {
11322 match parse_control(text) {
11323 Ok(ControlMessage::Hello { requires_sync, .. }) => {
11324 if requires_sync {
11325 self.set_sync_needed(true, true);
11327 }
11328 }
11329 Ok(ControlMessage::Presence {
11330 scope_key,
11331 kind,
11332 actor_id,
11333 client_id,
11334 doc,
11335 error,
11336 ..
11337 }) => {
11338 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
11339 }
11340 Ok(ControlMessage::Wake { .. }) => {
11341 self.set_sync_needed(true, true);
11343 }
11344 _ => {}
11345 }
11346 }
11347
11348 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
11351 if self.stopped {
11352 return;
11353 }
11354 #[cfg(feature = "bench-internals")]
11355 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
11356 let message = match decode_message(bytes) {
11357 Ok(m) if m.msg_kind == MsgKind::Response => m,
11358 _ => {
11359 self.set_sync_needed(true, true);
11360 return;
11361 }
11362 };
11363 #[cfg(feature = "bench-internals")]
11364 drop(decode_phase);
11365 let mut frames = message.frames.into_iter();
11366 let mut applied_cursor: Option<i64> = None;
11367 let mut any_covered = false;
11368 let mut dropped = false;
11369 while let Some(frame) = frames.next() {
11370 let Frame::SubStart {
11371 id,
11372 status,
11373 effective_scopes,
11374 ..
11375 } = frame
11376 else {
11377 continue;
11378 };
11379 let mut body = Vec::new();
11380 let mut next_cursor: Option<i64> = None;
11381 for inner in frames.by_ref() {
11382 match inner {
11383 Frame::SubEnd {
11384 next_cursor: nc, ..
11385 } => {
11386 next_cursor = Some(nc);
11387 break;
11388 }
11389 Frame::Unknown { .. } => {}
11390 other => body.push(other),
11391 }
11392 }
11393 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
11394 dropped = true;
11395 continue;
11396 };
11397 let sub = &self.subs[sub_index];
11398 if status != SubStatus::Active
11401 || sub.state != SubState::Active
11402 || sub.bootstrap_state.is_some()
11403 || !sub.synced_once
11404 {
11405 dropped = true;
11406 continue;
11407 }
11408 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
11409 any_covered = true;
11411 continue;
11412 }
11413 let previous_effective = self.subs[sub_index].effective.clone();
11414 let previous_cursor = self.subs[sub_index].cursor;
11415 self.subs[sub_index].effective = Some(effective_scopes);
11416 let mut failed = false;
11417 for inner in body {
11418 match inner {
11419 Frame::Commit {
11420 tables, changes, ..
11421 } => {
11422 if self.apply_commit_frame(&tables, &changes).is_err() {
11423 failed = true;
11424 break;
11425 }
11426 }
11427 _ => {
11428 failed = true;
11429 break;
11430 }
11431 }
11432 }
11433 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
11434 self.apply_sub_end(sub_index, cursor, None)
11435 .ok()
11436 .map(|()| cursor)
11437 });
11438 let Some(next_cursor) = committed else {
11439 self.subs[sub_index].effective = previous_effective;
11440 self.subs[sub_index].cursor = previous_cursor;
11441 dropped = true;
11442 continue;
11443 };
11444 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
11445 }
11446 if let Some(cursor) = applied_cursor {
11447 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11449 let _ = transport.realtime_send(&ack);
11450 } else if !any_covered || dropped {
11451 self.set_sync_needed(true, true);
11453 }
11454 }
11455
11456 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
11460 if !self.realtime_connected {
11461 return;
11462 }
11463 let floor = self
11464 .subs
11465 .iter()
11466 .filter(|s| {
11467 s.state == SubState::Active
11468 && s.bootstrap_state.is_none()
11469 && s.synced_once
11470 && s.cursor >= 0
11471 })
11472 .map(|s| s.cursor)
11473 .min();
11474 if let Some(cursor) = floor {
11475 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11476 let _ = transport.realtime_send(&ack);
11477 }
11478 }
11479}