1use crate::{ProgressObserver, ProgressPhase, ProgressState};
11#[cfg(test)]
12use ssp2::encode_message;
13#[cfg(test)]
14use std::cell::Cell;
15use std::cell::RefCell;
16use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
17
18use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
19use rusqlite::{Connection, OpenFlags, OptionalExtension};
20use serde::{Deserialize, Serialize};
21use serde_json::{json, Map, Value};
22use sha2::{Digest, Sha256};
23use ssp2::decode::WIRE_VERSION;
24use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
25use ssp2::primitives::RawJson;
26use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
27use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
28
29use crate::api::{
30 ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
31 ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
32 ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
33 CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
34 CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
35 DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters, DiagnosticSubscription,
36 FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget,
37 LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation, PresencePeer,
38 PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot, QueryValue,
39 RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
40 RetainedUniqueConflict, RowState, SchemaFloor, SubscriptionStateView, SyncIntent, SyncOutcome,
41 SyncReport, SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage,
42 WindowState, WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
43 MAX_DIAGNOSTIC_QUERY_FAILURES,
44};
45use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
46#[cfg(feature = "bench-internals")]
47use crate::bench::{Phase, Recorder};
48use crate::previous_version::{
49 capture_previous_version_from_replica, first_incompatibility,
50 reconcile_previous_version_at_boot, set_local_schema_descriptor,
51 sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
52 PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
53};
54use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
55use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
56use crate::values::{
57 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
58 encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
59 normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
60};
61
62const DEFAULT_ACCEPT: u8 = 0b0111;
66const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
67const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
68const ACCEPT_SQLITE: u8 = 1 << 2;
69const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
70const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
71
72pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
74const LOCAL_REVISION_KEY: &str = "localRevision";
75const CLIENT_ID_KEY: &str = "clientId";
76const LEASE_STATE_KEY: &str = "leaseState";
77const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
78const LOG_EPOCH_KEY: &str = "logEpoch";
79const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
84const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
85const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
86const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
89const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
92
93#[derive(Debug, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase", deny_unknown_fields)]
95struct PersistedLocalDataRebootstrapReceipt {
96 version: u8,
97 retained_commits: u64,
98 reset_subscriptions: u64,
99}
100
101fn invalid_local_rebootstrap_receipt() -> String {
102 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
103}
104
105fn encode_local_rebootstrap_receipt(
106 retained_commits: usize,
107 reset_subscriptions: usize,
108) -> Result<String, String> {
109 let retained_commits =
110 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
111 let reset_subscriptions =
112 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
113 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
114 return Err(invalid_local_rebootstrap_receipt());
115 }
116 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
117 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
118 retained_commits,
119 reset_subscriptions,
120 })
121 .map_err(|_| invalid_local_rebootstrap_receipt())
122}
123
124fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
125 if value == "v1" {
127 return Ok((0, 0));
128 }
129 let receipt: PersistedLocalDataRebootstrapReceipt =
130 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
131 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
132 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
133 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
134 {
135 return Err(invalid_local_rebootstrap_receipt());
136 }
137 Ok((
138 usize::try_from(receipt.retained_commits)
139 .map_err(|_| invalid_local_rebootstrap_receipt())?,
140 usize::try_from(receipt.reset_subscriptions)
141 .map_err(|_| invalid_local_rebootstrap_receipt())?,
142 ))
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146enum SubState {
147 Active,
148 Revoked,
149 Failed,
150}
151
152#[cfg(test)]
153mod observation_tests {
154 use super::*;
155 use crate::native_transport::HostTransport;
156 use crate::values::encode_row_json;
157 use serde_json::json;
158
159 #[test]
160 fn authority_policy_refuses_encrypted_and_undeclared_columns_before_open() {
161 let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
162 {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "secret", "type": "bytes", "encrypted": true, "declaredType": "string", "nullable": true}
163 ]}]});
164 for name in [
165 "secret",
166 "missing",
167 "*",
168 "id FROM authority; DELETE FROM authority",
169 "_syncular_version",
170 ] {
171 let limits = ClientLimits {
172 authority_reads: vec![crate::api::AuthorityReadDeclaration {
173 table: "authority".into(),
174 columns: vec!["id".into(), "actor_id".into(), name.into()],
175 scopes: [("actor_id".into(), vec!["a".into()])].into(),
176 }],
177 ..Default::default()
178 };
179 let error = SyncClient::new("authority".into(), &schema, limits)
180 .err()
181 .expect("forbidden policy");
182 assert!(
183 error.starts_with("client.authority_read_forbidden:"),
184 "{error}"
185 );
186 }
187 }
188
189 #[test]
190 fn authority_snapshot_observes_one_file_transaction_during_concurrent_writes() {
191 let path = std::env::temp_dir().join(format!(
192 "syncular-authority-snapshot-{}.db",
193 uuid::Uuid::new_v4()
194 ));
195 let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
196 {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "revision", "type": "integer", "nullable": false}
197 ]}]});
198 let limits = ClientLimits {
199 authority_reads: vec![crate::api::AuthorityReadDeclaration {
200 table: "authority".into(),
201 columns: vec!["id".into(), "actor_id".into(), "revision".into()],
202 scopes: [("actor_id".into(), vec!["a".into()])].into(),
203 }],
204 ..Default::default()
205 };
206 let mut client =
207 SyncClient::open_path("authority".into(), &schema, limits, path.to_str().unwrap())
208 .unwrap();
209 client.begin_security_preflight();
210 let update = |conn: &Connection, revision: i64| {
211 conn.execute_batch("BEGIN IMMEDIATE").unwrap();
212 conn.execute(
213 "INSERT OR REPLACE INTO _syncular_base_authority VALUES('one','a',?1,1)",
214 [revision],
215 )
216 .unwrap();
217 conn.execute(
218 "UPDATE _syncular_meta SET value=?1 WHERE key='localRevision'",
219 [revision.to_string()],
220 )
221 .unwrap();
222 conn.execute("INSERT OR REPLACE INTO _syncular_subscriptions VALUES('s','authority',?1)", [json!({"requested": {"actor_id": ["a"]}, "effectiveScopes": {"actor_id": ["a"]}, "status": "active", "cursor": revision}).to_string()]).unwrap();
223 conn.execute_batch("COMMIT").unwrap();
224 };
225 update(&client.conn, 1);
226 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
227 let writer_barrier = barrier.clone();
228 let writer_path = path.clone();
229 let writer = std::thread::spawn(move || {
230 let conn = Connection::open(writer_path).unwrap();
231 conn.busy_timeout(std::time::Duration::from_secs(5))
232 .unwrap();
233 writer_barrier.wait();
234 for revision in 2..=100 {
235 update(&conn, revision);
236 }
237 });
238 barrier.wait();
239 for _ in 0..100 {
240 let snapshot = client.authority_snapshot().unwrap();
241 let revision = snapshot["revision"]
242 .as_str()
243 .unwrap()
244 .parse::<i64>()
245 .unwrap();
246 assert_eq!(
247 snapshot["tables"][0]["rows"][0]["values"]["revision"],
248 revision
249 );
250 assert_eq!(snapshot["tables"][0]["persisted"][0]["cursor"], revision);
251 assert_eq!(snapshot["complete"], true);
252 }
253 writer.join().unwrap();
254 assert_eq!(client.authority_snapshot().unwrap()["revision"], "100");
255 drop(client);
256 let _ = std::fs::remove_file(path);
257 }
258
259 fn client() -> SyncClient {
260 SyncClient::new(
261 "retry-test".to_owned(),
262 &json!({
263 "version": 1,
264 "tables": [{
265 "name": "tasks",
266 "primaryKey": "id",
267 "columns": [
268 { "name": "id", "type": "string", "nullable": false },
269 { "name": "project_id", "type": "string", "nullable": false }
270 ],
271 "scopes": [{ "pattern": "project:{project_id}" }]
272 }]
273 }),
274 ClientLimits::default(),
275 )
276 .expect("test client")
277 }
278
279 #[cfg(feature = "e2ee")]
280 #[test]
281 fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
282 let schema = json!({
283 "version": 1,
284 "tables": [{
285 "name": "secrets",
286 "primaryKey": "id",
287 "columns": [
288 { "name": "id", "type": "string", "nullable": false },
289 { "name": "encryption_key_id", "type": "string", "nullable": true },
290 { "name": "note", "type": "bytes", "nullable": true,
291 "encrypted": true, "declaredType": "string" }
292 ],
293 "scopes": []
294 }]
295 });
296 let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
297 .expect("test client");
298 let mut config = crate::values::EncryptionConfig::default();
299 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
300 config
301 .key_id_columns
302 .insert("secrets".to_owned(), "encryption_key_id".to_owned());
303 client.set_encryption(config);
304 client
307 .write_base_row(
308 "secrets",
309 &vec![Some(ColumnValue::String("ghost".into())), None, None],
310 1,
311 )
312 .unwrap();
313 client.rebuild_overlay();
314 let commit_id = client
315 .patch(
316 "secrets",
317 "ghost",
318 Map::from_iter([("note".to_owned(), json!("x"))]),
319 None,
320 )
321 .expect("patch records without an author-time encrypt failure");
322 assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
323 assert!(client.drop_unencodable_outbox().unwrap());
325 assert!(client.pending_commit_ids().is_empty());
326 let rejections = client.rejections();
327 assert_eq!(rejections.len(), 1);
328 assert_eq!(rejections[0].code, "client.encrypt_failed");
329 assert_eq!(rejections[0].client_commit_id, commit_id);
330 assert_eq!(
331 client
332 .commit_outcome(&commit_id)
333 .unwrap()
334 .map(|outcome| outcome.status),
335 Some(CommitOutcomeStatus::Rejected)
336 );
337 }
338
339 #[test]
340 fn patch_with_a_scope_column_matches_the_stored_local_row() {
341 let mut client = client();
342 client.create_synced_tables().unwrap();
343 client
344 .conn
345 .execute(
346 "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
347 [],
348 )
349 .unwrap();
350
351 client
354 .patch(
355 "tasks",
356 "t1",
357 Map::from_iter([
358 ("project_id".to_owned(), json!("p1")),
359 ("id".to_owned(), json!("t1")),
360 ]),
361 None,
362 )
363 .expect("an equal scope column is a no-op");
364 let values = client.outbox[0].ops[0]
365 .values
366 .clone()
367 .expect("the patch records values");
368 assert_eq!(values.get("id"), Some(&json!("t1")));
369 assert!(!values.contains_key("project_id"));
370
371 let differing = client.patch(
373 "tasks",
374 "t1",
375 Map::from_iter([("project_id".to_owned(), json!("p2"))]),
376 None,
377 );
378 assert!(differing
379 .expect_err("a differing scope column is rejected")
380 .contains("patch cannot write scope column"));
381
382 let absent = client.patch(
385 "tasks",
386 "ghost",
387 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
388 None,
389 );
390 assert!(absent
391 .expect_err("an absent local row is rejected")
392 .starts_with("sync.row_missing:"));
393 assert_eq!(client.pending_commit_ids().len(), 1);
394 }
395
396 #[test]
397 fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
398 let schema = json!({
399 "version": 1,
400 "tables": [
401 {
402 "name": "tenants",
403 "primaryKey": "tenant_id",
404 "columns": [
405 { "name": "tenant_id", "type": "string", "nullable": false },
406 { "name": "body", "type": "string", "nullable": false }
407 ],
408 "scopes": [{ "pattern": "tenant:{tenant_id}" }]
409 },
410 {
411 "name": "buckets",
412 "primaryKey": "id",
413 "columns": [
414 { "name": "id", "type": "string", "nullable": false },
415 { "name": "bucket", "type": "float", "nullable": false },
416 { "name": "body", "type": "string", "nullable": false }
417 ],
418 "scopes": [{ "pattern": "bucket:{bucket}" }]
419 }
420 ]
421 });
422 let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
423 .expect("test client");
424 client.create_synced_tables().unwrap();
425
426 client
428 .conn
429 .execute(
430 "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
431 [],
432 )
433 .unwrap();
434 client
435 .patch(
436 "tenants",
437 "t1",
438 Map::from_iter([
439 ("tenant_id".to_owned(), json!("t1")),
440 ("body".to_owned(), json!("created")),
441 ]),
442 None,
443 )
444 .expect("the primary-key scope column is proven equal by construction");
445
446 client
448 .conn
449 .execute(
450 "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
451 [],
452 )
453 .unwrap();
454 client
455 .patch(
456 "buckets",
457 "b1",
458 Map::from_iter([
459 ("bucket".to_owned(), json!(2)),
460 ("body".to_owned(), json!("coerced")),
461 ]),
462 None,
463 )
464 .expect("the stored REAL value coerces from a supplied integer");
465 let values = client.outbox[1].ops[0]
466 .values
467 .clone()
468 .expect("the patch records values");
469 assert!(!values.contains_key("bucket"));
470
471 let differing = client.patch(
473 "buckets",
474 "b1",
475 Map::from_iter([("bucket".to_owned(), json!(3))]),
476 None,
477 );
478 assert!(differing
479 .expect_err("a differing scope value is rejected")
480 .contains("patch cannot write scope column"));
481 }
482
483 #[cfg(feature = "e2ee")]
484 #[test]
485 fn stored_key_fallback_resolves_an_integer_primary_key() {
486 let schema = json!({
487 "version": 1,
488 "tables": [{
489 "name": "patients",
490 "primaryKey": "id",
491 "columns": [
492 { "name": "id", "type": "integer", "nullable": false },
493 { "name": "encryption_key_id", "type": "string", "nullable": true },
494 { "name": "note", "type": "bytes", "nullable": true,
495 "encrypted": true, "declaredType": "string" }
496 ],
497 "scopes": []
498 }]
499 });
500 let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
501 .expect("test client");
502 client.create_synced_tables().unwrap();
503 client
504 .conn
505 .execute(
506 "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
507 VALUES (5, 'k1', NULL, 1)",
508 [],
509 )
510 .unwrap();
511 let mut config = crate::values::EncryptionConfig::default();
512 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
513 config
514 .key_id_columns
515 .insert("patients".to_owned(), "encryption_key_id".to_owned());
516 client.set_encryption(config);
517 let table = client.schema.table("patients").unwrap();
518 let fallback = client
519 .stored_key_fallback(table, "5")
520 .expect("the stored integer key resolves the fallback");
521 assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
522 }
523
524 #[test]
525 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
526 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
527 {"name":"id","type":"string","nullable":false},
528 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
529 for state in ["absent", "cached", "pinned"] {
530 let path = std::env::temp_dir().join(format!(
531 "syncular-blob-stage-{}.sqlite",
532 uuid::Uuid::new_v4()
533 ));
534 let mut client = SyncClient::open_path(
535 "stage-test".into(),
536 &schema,
537 Default::default(),
538 path.to_str().unwrap(),
539 )
540 .unwrap();
541 let bytes = b"atomic stage";
542 if state != "absent" {
543 client
544 .upload_blob(bytes, Some("text/plain".into()), None)
545 .unwrap();
546 if state == "cached" {
547 client
548 .conn
549 .execute("DELETE FROM _syncular_blob_uploads", [])
550 .unwrap();
551 }
552 }
553 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
554 client
555 .conn
556 .execute_batch(
557 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
558 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
559 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
560 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
561 )
562 .unwrap();
563 let error = client
564 .upload_blob(bytes, Some("application/octet-stream".into()), None)
565 .unwrap_err();
566 assert!(error.contains("injected stage failure"), "{state}: {error}");
567 assert!(client.conn.is_autocommit());
568 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
569 drop(client);
570 let mut reopened = SyncClient::open_path(
571 "stage-test".into(),
572 &schema,
573 Default::default(),
574 path.to_str().unwrap(),
575 )
576 .unwrap();
577 let durable = reopened
578 .query("SELECT * FROM _syncular_blobs", &[])
579 .unwrap();
580 reopened
581 .conn
582 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
583 .unwrap();
584 let retried = reopened.upload_blob(bytes, None, None).unwrap();
585 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
586 assert_eq!(
587 reopened
588 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
589 .unwrap(),
590 vec![Map::from_iter([(
591 "blob_id".into(),
592 Value::from(blob_id_for(bytes)),
593 )])]
594 );
595 drop(reopened);
596 std::fs::remove_file(&path).unwrap();
597 assert_eq!(durable, bodies, "{state}: reopened body state");
598 assert_eq!(immediate, bodies, "{state}: immediate body state");
599 }
600 }
601
602 #[test]
603 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
604 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
605 {"name":"id","type":"string","nullable":false},
606 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
607 for fault in [
608 "missing",
609 "body-type",
610 "length",
611 "hash",
612 "pin-metadata",
613 "read",
614 "pin-delete",
615 ] {
616 let path = std::env::temp_dir().join(format!(
617 "syncular-blob-upload-{}.sqlite",
618 uuid::Uuid::new_v4()
619 ));
620 let mut client = SyncClient::open_path(
621 "upload-test".into(),
622 &schema,
623 Default::default(),
624 path.to_str().unwrap(),
625 )
626 .unwrap();
627 let bytes = b"durable upload";
628 let ref_value = client
629 .upload_blob(bytes, Some("text/plain".into()), None)
630 .unwrap();
631 let commit = client
632 .mutate(vec![Mutation::Upsert {
633 table: "attachments".into(),
634 values: Map::from_iter([
635 ("id".into(), Value::from("pending")),
636 (
637 "file".into(),
638 Value::from(format!(
639 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
640 blob_id_for(bytes),
641 bytes.len()
642 )),
643 ),
644 ]),
645 base_version: None,
646 }])
647 .unwrap();
648 client
649 .conn
650 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
651 .unwrap();
652 client.conn.execute_batch(match fault {
653 "missing" => "DELETE FROM _syncular_blobs",
654 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
655 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
656 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
657 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
658 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
659 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
660 }).unwrap();
661 let state = if fault == "read" {
662 Vec::new()
663 } else {
664 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
665 };
666 let mut transport = CountingRealtimeTransport::default();
667 let error = client.flush_blob_uploads(&mut transport).unwrap_err();
668 assert_eq!(
669 error.code,
670 if matches!(fault, "read" | "pin-delete") {
671 "client.failed"
672 } else {
673 "sync.local_corrupt"
674 },
675 "{fault}"
676 );
677 assert_eq!(
678 transport.blob_uploads.len(),
679 usize::from(fault == "pin-delete"),
680 "{fault}: corrupt bytes must never be uploaded"
681 );
682 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
683 if fault != "read" {
684 assert_eq!(
685 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
686 state
687 );
688 } else {
689 client
690 .conn
691 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
692 .unwrap();
693 }
694 drop(client);
695 let mut reopened = SyncClient::open_path(
696 "upload-test".into(),
697 &schema,
698 Default::default(),
699 path.to_str().unwrap(),
700 )
701 .unwrap();
702 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
703 if fault != "read" {
704 assert_eq!(
705 reopened
706 .query("SELECT * FROM _syncular_blobs", &[])
707 .unwrap(),
708 state
709 );
710 }
711 if fault == "pin-delete" {
712 reopened
713 .conn
714 .execute_batch("DROP TRIGGER fail_pin_delete")
715 .unwrap();
716 }
717 if fault == "pin-metadata" {
718 reopened
719 .conn
720 .execute(
721 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
722 [],
723 )
724 .unwrap();
725 }
726 reopened
727 .conn
728 .execute_batch(
729 "DELETE FROM _syncular_blobs;
730 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
731 )
732 .unwrap();
733 transport.blob_uploads.clear();
734 reopened.flush_blob_uploads(&mut transport).unwrap();
735 assert_eq!(
736 transport.blob_uploads,
737 [(blob_id_for(bytes), bytes.to_vec())]
738 );
739 assert_eq!(
740 reopened
741 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
742 .unwrap(),
743 Vec::<Map<String, Value>>::new()
744 );
745 assert_eq!(reopened.pending_commit_ids(), [commit]);
746 assert_eq!(
747 reopened
748 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
749 .unwrap()
750 .unwrap()
751 .bytes,
752 bytes
753 );
754 drop(reopened);
755 std::fs::remove_file(path).unwrap();
756 }
757 }
758
759 #[test]
760 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
761 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
762 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
763 "scopes":[{"pattern":"project:{project_id}"}]}]});
764 for realtime in [false, true] {
765 for fault in [
766 "none",
767 "row",
768 "revision",
769 "commit",
770 "cursor",
771 "error",
772 "missing-end",
773 ] {
774 if realtime && fault == "missing-end" {
775 continue;
776 }
777 let path = std::env::temp_dir()
778 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
779 let mut client = SyncClient::open_path(
780 "frame-test".into(),
781 &schema,
782 Default::default(),
783 path.to_str().unwrap(),
784 )
785 .unwrap();
786 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
787 client
788 .subscribe(
789 "tasks".into(),
790 "tasks".into(),
791 vec![("project_id".into(), vec!["p1".into()])],
792 None,
793 )
794 .unwrap();
795 client.subs[0].cursor = 0;
796 client.subs[0].synced_once = true;
797 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
798 client.persist_sub(&client.subs[0]).unwrap();
799 client.realtime_state = RealtimeState::Connected;
800 client.realtime_reason_code = None;
801 client.drain_change_batches();
802 let mut transport = CountingRealtimeTransport::default();
803 let (_, meta) = client.build_request(false);
804 let mut response = Message {
805 wire_version: WIRE_VERSION,
806 msg_kind: MsgKind::Response,
807 frames: vec![
808 Frame::RespHeader {
809 required_schema_version: None,
810 latest_schema_version: None,
811 log_epoch: Some("epoch-1".into()),
812 reset_required: Some(false),
813 },
814 Frame::SubStart {
815 id: "tasks".into(),
816 status: SubStatus::Active,
817 reason_code: "".into(),
818 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
819 bootstrap: false,
820 },
821 ],
822 };
823 for (index, id) in ["first", "second"].iter().enumerate() {
824 let values = Map::from_iter([
825 ("id".into(), Value::from(*id)),
826 ("project_id".into(), Value::from("p1")),
827 ]);
828 response.frames.push(Frame::Commit {
829 commit_seq: index as i64 + 1,
830 actor_id: "writer".into(),
831 created_at_ms: 1,
832 tables: vec!["tasks".into()],
833 changes: vec![ssp2::model::Change {
834 table_index: 0,
835 row_id: (*id).into(),
836 op: Op::Upsert,
837 row_version: Some(index as i64 + 1),
838 scopes: vec![("project_id".into(), "p1".into())],
839 row: Some(
840 encode_row_json(
841 client.schema.table("tasks").unwrap(),
842 id,
843 &values,
844 &client.encryption,
845 )
846 .unwrap(),
847 ),
848 }],
849 });
850 }
851 response.frames.push(Frame::SubEnd {
852 next_cursor: 2,
853 bootstrap_state: None,
854 });
855 let valid = response.clone();
856 match fault {
857 "row" => {
858 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
859 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
860 },
861 "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(),
862 "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(),
863 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
864 "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});},
865 "missing-end" => {response.frames.truncate(3);},
866 _ => {}
867 }
868 if realtime {
869 client.on_realtime_binary(&mut transport, &encode_message(&response));
870 } else {
871 assert_eq!(
872 matches!(
873 client.process_response(&mut transport, response, &meta),
874 SyncOutcome::Ok(_)
875 ),
876 fault == "none",
877 "{fault}"
878 );
879 }
880 assert!(
881 client.conn.is_autocommit(),
882 "no open transaction after {realtime}/{fault}"
883 );
884 let count = if matches!(fault, "none" | "cursor") {
885 2
886 } else {
887 1
888 };
889 let expected_revision = count + u64::from(realtime && fault != "none");
890 assert_eq!(
891 client.local_revision(),
892 expected_revision,
893 "{realtime}/{fault}"
894 );
895 let batches = client.drain_change_batches();
896 assert_eq!(
897 batches.len(),
898 expected_revision as usize,
899 "{realtime}/{fault}"
900 );
901 let row_batches: Vec<_> = batches
902 .iter()
903 .filter(|batch| !batch.tables.is_empty())
904 .collect();
905 assert_eq!(row_batches.len(), count as usize);
906 assert_eq!(
907 row_batches
908 .iter()
909 .map(|b| b.revision.clone())
910 .collect::<Vec<_>>(),
911 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
912 );
913 for table in ["tasks", "_syncular_base_tasks"] {
914 assert_eq!(
915 client
916 .conn
917 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
918 .get::<_, u64>(0))
919 .unwrap(),
920 count,
921 "{realtime}/{fault}/{table}"
922 );
923 }
924 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
925 assert_eq!(
926 transport
927 .messages
928 .iter()
929 .filter(|m| m.contains("\"ack\""))
930 .count(),
931 usize::from(fault == "none")
932 );
933 if matches!(fault, "revision" | "commit" | "cursor") {
934 client
935 .conn
936 .execute_batch("DROP TRIGGER fail_frame")
937 .unwrap();
938 }
939 drop(client);
940 let mut reopened = SyncClient::open_path(
941 "frame-test".into(),
942 &schema,
943 Default::default(),
944 path.to_str().unwrap(),
945 )
946 .unwrap();
947 assert_eq!(reopened.local_revision(), expected_revision);
948 assert_eq!(
949 reopened
950 .query("SELECT id FROM tasks ORDER BY id", &[])
951 .unwrap()
952 .len(),
953 count as usize
954 );
955 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
956 let (_, meta) = reopened.build_request(false);
957 assert!(matches!(
958 reopened.process_response(&mut transport, valid, &meta),
959 SyncOutcome::Ok(_)
960 ));
961 assert_eq!(
962 reopened
963 .query("SELECT id FROM tasks ORDER BY id", &[])
964 .unwrap()
965 .len(),
966 2
967 );
968 assert_eq!(reopened.subs[0].cursor, 2);
969 drop(reopened);
970 std::fs::remove_file(path).unwrap();
971 }
972 }
973 }
974
975 #[test]
976 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
977 for status in [SubStatus::Reset, SubStatus::Revoked] {
978 for fault in ["subscription", "revision"] {
979 let mut client = client();
980 client
981 .subscribe(
982 "tasks".into(),
983 "tasks".into(),
984 vec![("project_id".into(), vec!["p1".into()])],
985 None,
986 )
987 .unwrap();
988 client.subs[0].cursor = 41;
989 client.subs[0].synced_once = true;
990 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
991 client.persist_sub(&client.subs[0]).unwrap();
992 client
993 .mutate(vec![Mutation::Upsert {
994 table: "tasks".into(),
995 values: Map::from_iter([
996 ("id".into(), Value::from("pending")),
997 ("project_id".into(), Value::from("p1")),
998 ]),
999 base_version: None,
1000 }])
1001 .unwrap();
1002 let ids = client.pending_commit_ids();
1003 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
1004 let revision = client.local_revision();
1005 client.drain_change_batches();
1006 let (_, meta) = client.build_request(false);
1007 client.conn.execute_batch(if fault == "subscription" {
1008 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
1009 } else {
1010 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
1011 }).unwrap();
1012 let mut transport = CountingRealtimeTransport::default();
1013 let mut report = SyncReport::default();
1014 if status == SubStatus::Reset {
1016 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
1017 }
1018 let result = client.process_section(
1019 &mut transport,
1020 "tasks",
1021 status,
1022 "sync.scope_revoked",
1023 vec![],
1024 vec![],
1025 Some((42, None)),
1026 &meta,
1027 &mut report,
1028 );
1029 assert!(result.is_err(), "{status:?}/{fault}");
1030 assert!(client.conn.is_autocommit());
1031 assert_eq!(client.subs[0].cursor, 41);
1032 assert_eq!(client.subs[0].state, SubState::Active);
1033 assert_eq!(client.pending_commit_ids(), ids);
1034 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1035 assert!(client.rejections.is_empty());
1036 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
1037 assert_eq!(client.local_revision(), revision);
1038 assert!(client.drain_change_batches().is_empty());
1039 assert!(report.revoked.is_empty() && report.resets.is_empty());
1040 }
1041 }
1042 }
1043
1044 #[test]
1045 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
1046 let mut client = client();
1047 client
1048 .subscribe(
1049 "tasks".into(),
1050 "tasks".into(),
1051 vec![("project_id".into(), vec!["p1".into()])],
1052 None,
1053 )
1054 .unwrap();
1055 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1056 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
1057 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
1058 let row = |id: &str| ssp2::segment::SegmentRow {
1059 server_version: 1,
1060 values: vec![
1061 Some(ColumnValue::String(id.into())),
1062 Some(ColumnValue::String("p1".into())),
1063 ],
1064 };
1065 let segment = RowsSegment {
1066 table: "tasks".into(),
1067 schema_version: 1,
1068 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
1069 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
1070 };
1071 assert!(client.apply_segment(0, &segment, true).is_err());
1072 assert!(client.conn.is_autocommit());
1073 assert_eq!(
1074 client
1075 .query("SELECT id FROM tasks ORDER BY id", &[])
1076 .unwrap(),
1077 vec![Map::from_iter([("id".into(), Value::from("first"))])]
1078 );
1079 assert_eq!(client.local_revision(), 1);
1080 assert_eq!(client.drain_change_batches().len(), 1);
1081 assert_eq!(client.subs[0].cursor, -1);
1082 client
1083 .conn
1084 .execute_batch("DROP TRIGGER fail_block")
1085 .unwrap();
1086 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
1087 assert_eq!(client.local_revision(), 3);
1088 assert_eq!(client.drain_change_batches().len(), 2);
1089 }
1090
1091 #[test]
1092 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1093 assert_eq!(
1094 client().diagnostics_storage().blob_cache_bytes_approx,
1095 Some(0)
1096 );
1097 let client = SyncClient::new(
1098 "diagnostic-storage".to_owned(),
1099 &json!({
1100 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1101 "columns": [{"name": "id", "type": "string", "nullable": false},
1102 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1103 }),
1104 ClientLimits {
1105 blob_cache_max_bytes: Some(5),
1106 ..Default::default()
1107 },
1108 )
1109 .unwrap();
1110 let empty = client.diagnostics_storage();
1111 assert_eq!(empty.status, "healthy");
1112 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1113 assert_eq!(empty.retained_outcome_entries, Some(0));
1114 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1115 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1116 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1117 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1118 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1119 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1120 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1121 let written = client.diagnostics_storage();
1122 assert_eq!(written.status, "pressure");
1123 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1124 assert_eq!(written.retained_outcome_entries, Some(2));
1125 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1126 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1127 assert_eq!(
1128 written.pressure_reason_code.as_deref(),
1129 Some("client.blob_cache_over_limit")
1130 );
1131 client
1132 .conn
1133 .execute_batch(
1134 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1135 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1136 )
1137 .unwrap();
1138 let staged = client.diagnostics_storage();
1139 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1140 assert_eq!(staged.retained_outcome_entries, Some(0));
1141 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1142 assert_eq!(staged.status, "healthy");
1143 client.conn.execute_batch("ROLLBACK").unwrap();
1144 assert_eq!(client.diagnostics_storage(), written);
1145 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1146 let unreadable = client.diagnostics_storage();
1147 assert_eq!(unreadable.status, "unreadable");
1148 assert!(unreadable.database_bytes_approx.is_none());
1149 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1150 assert!(unreadable.retained_outcome_entries.is_none());
1151 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1152 assert!(unreadable.blob_cache_bytes_approx.is_none());
1153 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1154 let restored = client.diagnostics_storage();
1155 assert_eq!(restored.status, written.status);
1156 assert_eq!(
1157 restored.retained_outcome_bytes_approx,
1158 written.retained_outcome_bytes_approx
1159 );
1160 client.conn.set_prepared_statement_cache_capacity(0);
1161 assert_eq!(client.diagnostics_storage(), restored);
1162 }
1163
1164 struct ServerErrorTransport {
1166 code: &'static str,
1167 retryable: bool,
1168 }
1169
1170 impl Transport for ServerErrorTransport {
1171 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1172 Ok(encode_message(&Message {
1173 wire_version: WIRE_VERSION,
1174 msg_kind: MsgKind::Response,
1175 frames: vec![
1176 Frame::RespHeader {
1177 required_schema_version: None,
1178 latest_schema_version: None,
1179 log_epoch: Some("epoch-1".to_owned()),
1180 reset_required: Some(false),
1181 },
1182 Frame::Error {
1183 code: self.code.to_owned(),
1184 message: "server error".to_owned(),
1185 category: "internal".to_owned(),
1186 retryable: self.retryable,
1187 recommended_action: "retryLater".to_owned(),
1188 details: None,
1189 },
1190 ],
1191 }))
1192 }
1193
1194 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1195 self.sync(request)
1196 }
1197
1198 fn download_segment(
1199 &mut self,
1200 _request: &SegmentRequest,
1201 _on_progress: &mut dyn FnMut(u64),
1202 ) -> Result<Vec<u8>, TransportError> {
1203 Err(TransportError::new("sync.transport_failed", "offline"))
1204 }
1205
1206 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1207 Ok(())
1208 }
1209
1210 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1211 Ok(())
1212 }
1213
1214 fn realtime_close(&mut self) -> Result<(), TransportError> {
1215 Ok(())
1216 }
1217 }
1218
1219 #[test]
1220 fn retryable_server_error_schedules_a_background_retry() {
1221 let mut client = client();
1222 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1223 let mut transport = ServerErrorTransport {
1224 code: "sync.internal_error",
1225 retryable: true,
1226 };
1227 assert!(matches!(
1228 client.sync(&mut transport),
1229 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1230 ));
1231 assert!(matches!(
1232 client.drain_sync_intents().as_slice(),
1233 [SyncIntent::Background { delay_ms: 250 }]
1234 ));
1235 assert_eq!(
1236 client.progress().snapshot().unwrap().retry_delay_ms,
1237 Some(250)
1238 );
1239 let mut transport = ServerErrorTransport {
1240 code: "sync.invalid_request",
1241 retryable: false,
1242 };
1243 assert!(matches!(
1244 client.sync(&mut transport),
1245 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1246 ));
1247 assert!(client.drain_sync_intents().is_empty());
1248 let progress = client.progress().snapshot().unwrap();
1249 assert_eq!(progress.state, ProgressState::Failed);
1250 assert_eq!(progress.retry_delay_ms, None);
1251 }
1252
1253 #[test]
1254 fn failed_progress_retry_delay_doubles_to_the_cap() {
1255 let mut client = client();
1256 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1257 let mut transport = ServerErrorTransport {
1258 code: "sync.internal_error",
1259 retryable: true,
1260 };
1261 let delays: Vec<Option<u64>> = (0..9)
1262 .map(|_| {
1263 client.sync(&mut transport);
1264 client.progress().snapshot().unwrap().retry_delay_ms
1265 })
1266 .collect();
1267 assert_eq!(
1268 delays,
1269 [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
1270 );
1271 }
1272
1273 #[test]
1274 fn background_retry_deadlines_back_off_and_reset() {
1275 let mut client = client();
1276 client.schedule_background_retry();
1277 assert!(matches!(
1278 client.drain_sync_intents().as_slice(),
1279 [SyncIntent::Background { delay_ms: 250 }]
1280 ));
1281 client.schedule_background_retry();
1282 assert!(matches!(
1283 client.drain_sync_intents().as_slice(),
1284 [SyncIntent::Background { delay_ms: 500 }]
1285 ));
1286 client.reset_background_retry();
1287 client.schedule_background_retry();
1288 assert!(matches!(
1289 client.drain_sync_intents().as_slice(),
1290 [SyncIntent::Background { delay_ms: 250 }]
1291 ));
1292 }
1293
1294 #[test]
1295 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1296 let mut client = client();
1297 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1298 client
1299 .subscribe(
1300 "epoch-tasks".to_owned(),
1301 "tasks".to_owned(),
1302 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1303 None,
1304 )
1305 .expect("subscribe");
1306 client.drain_sync_intents();
1307
1308 client
1309 .run_log_epoch_reset("epoch-2")
1310 .expect("reset partition log epoch");
1311
1312 assert!(matches!(
1313 client.drain_sync_intents().as_slice(),
1314 [SyncIntent::Interactive]
1315 ));
1316 }
1317
1318 #[derive(Default)]
1319 struct CountingRealtimeTransport {
1320 connects: usize,
1321 closes: usize,
1322 messages: Vec<String>,
1323 blob_uploads: Vec<(String, Vec<u8>)>,
1324 blob_download: Option<Vec<u8>>,
1325 sync_calls: usize,
1326 realtime_sync_calls: usize,
1327 refuse_connect: bool,
1328 }
1329
1330 impl Transport for CountingRealtimeTransport {
1331 fn blob_upload(
1332 &mut self,
1333 blob_id: &str,
1334 bytes: &[u8],
1335 _media_type: Option<&str>,
1336 ) -> Result<(), TransportError> {
1337 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1338 Ok(())
1339 }
1340
1341 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1342 self.blob_download
1343 .clone()
1344 .map(BlobDownload::Bytes)
1345 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1346 }
1347
1348 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1349 self.sync_calls += 1;
1350 Err(TransportError::new("sync.transport_failed", "offline"))
1351 }
1352
1353 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1354 self.realtime_sync_calls += 1;
1355 Err(TransportError::new("sync.transport_failed", "offline"))
1356 }
1357
1358 fn download_segment(
1359 &mut self,
1360 _request: &SegmentRequest,
1361 _on_progress: &mut dyn FnMut(u64),
1362 ) -> Result<Vec<u8>, TransportError> {
1363 Err(TransportError::new("sync.transport_failed", "offline"))
1364 }
1365
1366 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1367 self.connects += 1;
1368 if self.refuse_connect {
1369 return Err(TransportError::new("transport.failed", "refused"));
1370 }
1371 Ok(())
1372 }
1373
1374 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1375 self.messages.push(text.to_owned());
1376 Ok(())
1377 }
1378
1379 fn realtime_close(&mut self) -> Result<(), TransportError> {
1380 self.closes += 1;
1381 Ok(())
1382 }
1383 }
1384
1385 #[test]
1386 fn blob_results_own_downloaded_bytes() {
1387 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1388 {"name":"id","type":"string","nullable":false},
1389 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1390 let bytes = b"owned typed result".to_vec();
1391 let blob_id = blob_id_for(&bytes);
1392 let mut transport = CountingRealtimeTransport {
1393 blob_download: Some(bytes.clone()),
1394 ..Default::default()
1395 };
1396 let result = {
1397 let mut client =
1398 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1399 .unwrap();
1400 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1401 assert_eq!(result.blob_id, blob_id);
1402 assert_eq!(result.byte_length, bytes.len() as i64);
1403 assert_eq!(result.bytes, bytes);
1404 assert_eq!(result.media_type, None);
1405 assert_eq!(
1406 client
1407 .conn
1408 .query_row(
1409 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1410 rusqlite::params![blob_id],
1411 |row| row.get::<_, i64>(0),
1412 )
1413 .unwrap(),
1414 0
1415 );
1416 result
1417 };
1418 assert_eq!(result.bytes, bytes);
1419
1420 let mut client =
1421 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1422 assert_eq!(
1423 client
1424 .fetch_blob_bytes(&mut transport, "not json")
1425 .unwrap_err(),
1426 (
1427 "client.failed".to_owned(),
1428 "blob ref is not JSON".to_owned()
1429 )
1430 );
1431 }
1432
1433 #[test]
1434 fn visible_blob_references_survive_cache_trimming() {
1435 let schema = json!({"version":1,"tables":[
1436 {"name":"attachments","primaryKey":"id","columns":[
1437 {"name":"id","type":"string","nullable":false},
1438 {"name":"body","type":"blob_ref","nullable":true},
1439 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1440 {"name":"avatars","primaryKey":"id","columns":[
1441 {"name":"id","type":"string","nullable":false},
1442 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1443 ]});
1444 let bytes = b"referenced above cap".to_vec();
1445 let blob_id = blob_id_for(&bytes);
1446 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1447 let mut client = SyncClient::new(
1448 "visible-blob-refs".to_owned(),
1449 &schema,
1450 ClientLimits {
1451 blob_cache_max_bytes: Some(1),
1452 ..Default::default()
1453 },
1454 )
1455 .unwrap();
1456 client.conn.execute(
1457 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1458 rusqlite::params![reference, reference, reference, reference],
1459 ).unwrap();
1460 client
1461 .conn
1462 .execute(
1463 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1464 rusqlite::params![reference],
1465 )
1466 .unwrap();
1467 client.conn.execute(
1468 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1469 [],
1470 ).unwrap();
1471 let mut transport = CountingRealtimeTransport {
1472 blob_download: Some(bytes.clone()),
1473 ..Default::default()
1474 };
1475
1476 assert_eq!(
1477 client
1478 .fetch_blob_bytes(&mut transport, &blob_id)
1479 .unwrap()
1480 .bytes,
1481 bytes
1482 );
1483 let rows = client
1484 .conn
1485 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1486 .unwrap()
1487 .query_map([], |row| row.get::<_, String>(0))
1488 .unwrap()
1489 .collect::<Result<Vec<_>, _>>()
1490 .unwrap();
1491 assert_eq!(rows, vec![blob_id]);
1492 }
1493
1494 #[test]
1495 fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
1496 let mut transport = CountingRealtimeTransport::default();
1497 let path = std::env::temp_dir().join(format!(
1498 "syncular-transport-gate-{}.db",
1499 uuid::Uuid::new_v4()
1500 ));
1501 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1502 {"name":"id","type":"string","nullable":false},
1503 {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
1504 let mut client = SyncClient::open_path(
1505 "gate".into(),
1506 &schema,
1507 ClientLimits::default(),
1508 path.to_str().unwrap(),
1509 )
1510 .unwrap();
1511 client.connect_realtime(&mut transport).unwrap();
1512 client.set_transport_enabled(&mut transport, false);
1513 assert_eq!(transport.closes, 1);
1514 client.begin_security_preflight();
1515 client.activate_security(Default::default()).unwrap();
1516 client
1517 .subscribe(
1518 "tasks".into(),
1519 "tasks".into(),
1520 vec![("project_id".into(), vec!["p1".into()])],
1521 None,
1522 )
1523 .unwrap();
1524 for id in ["one", "two"] {
1525 client
1526 .mutate(vec![Mutation::Upsert {
1527 table: "tasks".into(),
1528 values: Map::from_iter([
1529 ("id".into(), json!(id)),
1530 ("project_id".into(), json!("p1")),
1531 ]),
1532 base_version: None,
1533 }])
1534 .unwrap();
1535 }
1536 assert_eq!(
1537 client
1538 .query("SELECT id FROM tasks ORDER BY id", &[])
1539 .unwrap()
1540 .len(),
1541 2
1542 );
1543 assert!(
1544 matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
1545 );
1546 assert!(client
1547 .connect_realtime(&mut transport)
1548 .unwrap_err()
1549 .starts_with("sync.offline:"));
1550 assert!(client
1551 .set_presence(&mut transport, "project:p1", None)
1552 .unwrap_err()
1553 .starts_with("sync.offline:"));
1554 assert_eq!(transport.sync_calls, 0);
1555 assert_eq!(transport.realtime_sync_calls, 0);
1556 assert_eq!(transport.connects, 1);
1557 assert!(transport.messages.is_empty());
1558 assert!(matches!(
1559 client.drain_sync_intents().as_slice(),
1560 [SyncIntent::None]
1561 ));
1562 let pending = client.pending_commit_ids();
1563 drop(client);
1564 let mut reopened = SyncClient::open_path(
1565 "gate".into(),
1566 &schema,
1567 ClientLimits::default(),
1568 path.to_str().unwrap(),
1569 )
1570 .unwrap();
1571 assert!(reopened.transport_enabled());
1572 reopened.set_transport_enabled(&mut transport, false);
1573 assert_eq!(reopened.pending_commit_ids(), pending);
1574 assert_eq!(
1575 reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
1576 2
1577 );
1578 reopened.set_transport_enabled(&mut transport, true);
1579 assert!(matches!(
1580 reopened.drain_sync_intents().as_slice(),
1581 [SyncIntent::Interactive]
1582 ));
1583 reopened.set_transport_enabled(&mut transport, true);
1584 assert!(reopened.drain_sync_intents().is_empty());
1585 drop(reopened);
1586 std::fs::remove_file(path).unwrap();
1587 }
1588
1589 #[test]
1590 fn realtime_connection_ownership_is_idempotent() {
1591 let mut client = client();
1592 let mut transport = CountingRealtimeTransport {
1593 connects: 0,
1594 closes: 0,
1595 ..Default::default()
1596 };
1597 client
1598 .connect_realtime(&mut transport)
1599 .expect("first connect");
1600 client
1601 .connect_realtime(&mut transport)
1602 .expect("idempotent connect");
1603 assert_eq!(transport.connects, 1);
1604 client.disconnect_realtime(&mut transport);
1605 client.disconnect_realtime(&mut transport);
1606 assert_eq!(transport.closes, 1);
1607 client
1608 .connect_realtime(&mut transport)
1609 .expect("deliberate reconnect");
1610 assert_eq!(transport.connects, 2);
1611 }
1612
1613 #[test]
1614 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1615 let mut client = client();
1616 client
1617 .set_realtime_policy(RealtimePolicy::Required)
1618 .expect("set policy");
1619 let mut transport = CountingRealtimeTransport::default();
1620 match client.sync(&mut transport) {
1621 SyncOutcome::RealtimeUnavailable {
1622 state,
1623 reason_code,
1624 retry_delay_ms,
1625 } => {
1626 assert_eq!(state, RealtimeState::Disconnected);
1627 assert_eq!(reason_code, None);
1628 assert_eq!(retry_delay_ms, 250);
1629 }
1630 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1631 }
1632 assert_eq!(transport.sync_calls, 0);
1633 assert!(matches!(
1634 client.drain_sync_intents().as_slice(),
1635 [SyncIntent::Background { delay_ms: 250 }]
1636 ));
1637 let snapshot = client
1638 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1639 .expect("diagnostics");
1640 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1641 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1642 assert_eq!(snapshot.host.realtime_reason_code, None);
1643 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1644 assert_eq!(
1645 snapshot
1646 .last_round
1647 .as_ref()
1648 .and_then(|round| round.error_code.clone()),
1649 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1650 );
1651 }
1652
1653 #[test]
1654 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1655 let mut client = client();
1656 client
1657 .set_realtime_policy(RealtimePolicy::Required)
1658 .expect("set policy");
1659 let mut transport = CountingRealtimeTransport {
1660 refuse_connect: true,
1661 ..Default::default()
1662 };
1663 assert!(client.connect_realtime(&mut transport).is_err());
1664 assert_eq!(client.realtime_state(), RealtimeState::Refused);
1665 let snapshot = client
1666 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1667 .expect("diagnostics");
1668 assert_eq!(
1669 snapshot.host.realtime_reason_code.as_deref(),
1670 Some("transport.failed")
1671 );
1672 match client.sync(&mut transport) {
1673 SyncOutcome::RealtimeUnavailable {
1674 state, reason_code, ..
1675 } => {
1676 assert_eq!(state, RealtimeState::Refused);
1677 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1678 }
1679 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1680 }
1681 assert_eq!(transport.sync_calls, 0);
1682 assert_eq!(transport.realtime_sync_calls, 0);
1683 }
1684
1685 #[test]
1686 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1687 let mut client = client();
1688 client
1689 .set_realtime_policy(RealtimePolicy::Required)
1690 .expect("set policy");
1691 let mut transport = CountingRealtimeTransport::default();
1692 client.connect_realtime(&mut transport).expect("connect");
1693 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1694 assert!(
1695 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1696 "a policy change cannot silently re-route a connected socket"
1697 );
1698 assert!(matches!(
1699 client.sync(&mut transport),
1700 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1701 ));
1702 assert_eq!(client.realtime_state(), RealtimeState::Lost);
1703 assert_eq!(transport.realtime_sync_calls, 1);
1704 assert_eq!(transport.sync_calls, 0);
1705 let snapshot = client
1706 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1707 .expect("diagnostics");
1708 assert_eq!(
1709 snapshot.host.realtime_reason_code.as_deref(),
1710 Some("sync.transport_failed")
1711 );
1712 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1713 match client.sync(&mut transport) {
1714 SyncOutcome::RealtimeUnavailable {
1715 state, reason_code, ..
1716 } => {
1717 assert_eq!(state, RealtimeState::Lost);
1718 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1719 }
1720 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1721 }
1722 assert_eq!(transport.sync_calls, 0);
1723 client.disconnect_realtime(&mut transport);
1724 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1725 assert_eq!(transport.closes, 1);
1726 client.connect_realtime(&mut transport).expect("reconnect");
1727 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1728 assert_eq!(transport.connects, 2);
1729 }
1730
1731 #[test]
1732 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1733 let mut client = client();
1734 client
1735 .set_realtime_policy(RealtimePolicy::Off)
1736 .expect("set policy");
1737 let mut transport = CountingRealtimeTransport::default();
1738 assert!(client.connect_realtime(&mut transport).is_err());
1739 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1740 assert!(matches!(
1741 client.sync(&mut transport),
1742 SyncOutcome::Failed { .. }
1743 ));
1744 assert_eq!(transport.sync_calls, 1);
1745 assert_eq!(transport.realtime_sync_calls, 0);
1746 let snapshot = client
1747 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1748 .expect("diagnostics");
1749 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1750 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1751 }
1752
1753 #[test]
1754 fn optional_policy_keeps_the_http_round() {
1755 let mut client = client();
1756 let mut transport = CountingRealtimeTransport::default();
1757 assert!(matches!(
1758 client.sync(&mut transport),
1759 SyncOutcome::Failed { .. }
1760 ));
1761 assert_eq!(transport.sync_calls, 1);
1762 assert_eq!(transport.realtime_sync_calls, 0);
1763 let snapshot = client
1764 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1765 .expect("diagnostics");
1766 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1767 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1768 }
1769
1770 #[test]
1771 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1772 let mut client = client();
1773 client
1774 .subscribe(
1775 "repair-tasks".to_owned(),
1776 "tasks".to_owned(),
1777 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1778 None,
1779 )
1780 .expect("subscribe");
1781 {
1782 let sub = client
1783 .subs
1784 .iter_mut()
1785 .find(|sub| sub.id == "repair-tasks")
1786 .expect("subscription");
1787 sub.cursor = 42;
1788 sub.synced_once = true;
1789 let persisted = sub.clone();
1790 client.persist_sub(&persisted).unwrap();
1791 }
1792 for table in ["_syncular_base_tasks", "tasks"] {
1793 client
1794 .conn
1795 .execute(
1796 &format!(
1797 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1798 ),
1799 rusqlite::params!["server-row", "p1"],
1800 )
1801 .expect("seed server row");
1802 }
1803 let pending = client
1804 .mutate(vec![Mutation::Upsert {
1805 table: "tasks".to_owned(),
1806 values: Map::from_iter([
1807 ("id".to_owned(), Value::from("offline-row")),
1808 ("project_id".to_owned(), Value::from("p1")),
1809 ]),
1810 base_version: None,
1811 }])
1812 .expect("queue offline work");
1813 client.drain_change_batches();
1814 client.drain_sync_intents();
1815
1816 assert_eq!(
1817 client
1818 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1819 rebootstrap_id: "support-case-001".to_owned(),
1820 })
1821 .expect("rebootstrap"),
1822 LocalDataRebootstrapResult {
1823 already_applied: false,
1824 retained_commits: 1,
1825 reset_subscriptions: 1,
1826 }
1827 );
1828 let visible_ids = client
1829 .conn
1830 .prepare("SELECT id FROM tasks ORDER BY id")
1831 .expect("prepare visible ids")
1832 .query_map([], |row| row.get::<_, String>(0))
1833 .expect("query visible ids")
1834 .collect::<Result<Vec<_>, _>>()
1835 .expect("collect visible ids");
1836 assert_eq!(visible_ids, vec!["offline-row"]);
1837 assert_eq!(client.pending_commit_ids(), vec![pending]);
1838 assert_eq!(
1839 client
1840 .subscription_state("repair-tasks")
1841 .expect("subscription")
1842 .cursor,
1843 -1
1844 );
1845 assert!(client.upgrading());
1846 assert!(client.sync_needed());
1847 assert!(matches!(
1848 client.drain_sync_intents().as_slice(),
1849 [SyncIntent::Interactive]
1850 ));
1851 assert_eq!(client.drain_change_batches().len(), 1);
1852
1853 assert_eq!(
1854 client
1855 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1856 rebootstrap_id: "support-case-001".to_owned(),
1857 })
1858 .expect("idempotent retry"),
1859 LocalDataRebootstrapResult {
1860 already_applied: true,
1861 retained_commits: 1,
1862 reset_subscriptions: 1,
1863 }
1864 );
1865 assert!(client.drain_change_batches().is_empty());
1866 }
1867
1868 #[test]
1869 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1870 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1871 assert_eq!(
1872 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1873 (3, 4)
1874 );
1875 assert_eq!(
1876 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1877 (0, 0)
1878 );
1879 for malformed in [
1880 "",
1881 "{}",
1882 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1883 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1884 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1885 ] {
1886 assert_eq!(
1887 decode_local_rebootstrap_receipt(malformed)
1888 .expect_err("malformed receipt must fail"),
1889 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1890 );
1891 }
1892 }
1893
1894 #[test]
1895 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1896 let path = std::env::temp_dir().join(format!(
1897 "syncular-rebootstrap-receipt-{}.db",
1898 uuid::Uuid::new_v4()
1899 ));
1900 let schema = json!({
1901 "version": 1,
1902 "tables": [{
1903 "name": "tasks",
1904 "primaryKey": "id",
1905 "columns": [
1906 { "name": "id", "type": "string", "nullable": false },
1907 { "name": "project_id", "type": "string", "nullable": false }
1908 ],
1909 "scopes": [{ "pattern": "project:{project_id}" }]
1910 }]
1911 });
1912 let path_string = path.to_str().expect("UTF-8 temp path");
1913
1914 {
1915 let mut first = SyncClient::open_path(
1916 "repair-restart-client".to_owned(),
1917 &schema,
1918 ClientLimits::default(),
1919 path_string,
1920 )
1921 .expect("first open");
1922 first
1923 .subscribe(
1924 "repair-tasks".to_owned(),
1925 "tasks".to_owned(),
1926 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1927 None,
1928 )
1929 .expect("subscribe");
1930 first
1931 .mutate(vec![Mutation::Upsert {
1932 table: "tasks".to_owned(),
1933 values: Map::from_iter([
1934 ("id".to_owned(), Value::from("offline-row")),
1935 ("project_id".to_owned(), Value::from("p1")),
1936 ]),
1937 base_version: None,
1938 }])
1939 .expect("queue offline work");
1940 assert_eq!(
1941 first
1942 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1943 rebootstrap_id: "restart-receipt".to_owned(),
1944 })
1945 .expect("first rebootstrap"),
1946 LocalDataRebootstrapResult {
1947 already_applied: false,
1948 retained_commits: 1,
1949 reset_subscriptions: 1,
1950 }
1951 );
1952 }
1953
1954 let mut reopened = SyncClient::open_path(
1955 "repair-restart-client".to_owned(),
1956 &schema,
1957 ClientLimits::default(),
1958 path_string,
1959 )
1960 .expect("reopen");
1961 reopened.drain_change_batches();
1962 reopened.drain_sync_intents();
1963 assert_eq!(
1964 reopened
1965 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1966 rebootstrap_id: "restart-receipt".to_owned(),
1967 })
1968 .expect("receipt replay"),
1969 LocalDataRebootstrapResult {
1970 already_applied: true,
1971 retained_commits: 1,
1972 reset_subscriptions: 1,
1973 }
1974 );
1975 assert!(reopened.drain_change_batches().is_empty());
1976 assert!(reopened.drain_sync_intents().is_empty());
1977 drop(reopened);
1978 std::fs::remove_file(path).expect("remove temp database");
1979 }
1980
1981 #[test]
1982 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1983 let mut malformed = client();
1984 malformed
1985 .subscribe(
1986 "repair-tasks".to_owned(),
1987 "tasks".to_owned(),
1988 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1989 None,
1990 )
1991 .expect("subscribe");
1992 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1993 malformed.drain_change_batches();
1994 malformed.drain_sync_intents();
1995 let malformed_error = malformed
1996 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1997 rebootstrap_id: "malformed".to_owned(),
1998 })
1999 .expect_err("malformed receipt must fail closed");
2000 assert_eq!(
2001 malformed_error,
2002 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
2003 );
2004 assert!(!malformed.upgrading());
2005 assert_eq!(
2006 malformed
2007 .subscription_state("repair-tasks")
2008 .expect("unchanged subscription")
2009 .cursor,
2010 -1
2011 );
2012 assert!(malformed.drain_change_batches().is_empty());
2013 assert!(malformed.drain_sync_intents().is_empty());
2014
2015 let mut unreadable = client();
2016 unreadable
2017 .conn
2018 .execute(
2019 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
2020 rusqlite::params!["server-row", "p1"],
2021 )
2022 .expect("seed visible row");
2023 unreadable
2024 .conn
2025 .execute("DROP TABLE _syncular_meta", [])
2026 .expect("break marker storage");
2027 let unreadable_error = unreadable
2028 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2029 rebootstrap_id: "unreadable".to_owned(),
2030 })
2031 .expect_err("unreadable marker storage must fail closed");
2032 assert_eq!(
2033 unreadable_error,
2034 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
2035 );
2036 let visible_rows = unreadable
2037 .conn
2038 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
2039 .expect("visible projection remains");
2040 assert_eq!(visible_rows, 1);
2041 assert!(!unreadable.upgrading());
2042 assert!(unreadable.drain_change_batches().is_empty());
2043 assert!(unreadable.drain_sync_intents().is_empty());
2044 }
2045
2046 #[test]
2047 fn local_rebootstrap_cannot_bypass_schema_floor() {
2048 let mut client = client();
2049 client.set_schema_floor(Some(SchemaFloor {
2050 required_schema_version: Some(2),
2051 latest_schema_version: Some(2),
2052 }));
2053 let error = client
2054 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2055 rebootstrap_id: "blocked-floor".to_owned(),
2056 })
2057 .expect_err("schema floor must block repair");
2058 assert!(error.contains("cannot bypass an active schema-floor stop"));
2059 }
2060
2061 #[test]
2062 fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
2063 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2064 "columns":[{"name":"id","type":"string","nullable":false},
2065 {"name":"project_id","type":"string","nullable":false},
2066 {"name":"starts_at_ms","type":"integer","nullable":false},
2067 {"name":"version","type":"integer","nullable":false}],
2068 "scopes":[{"pattern":"project:{project_id}"}]}]});
2069 let mut client =
2070 SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
2071 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2072 client
2073 .write_base_row(
2074 "tasks",
2075 &vec![
2076 Some(ColumnValue::String("t1".into())),
2077 Some(ColumnValue::String("p1".into())),
2078 Some(ColumnValue::Integer(100)),
2079 Some(ColumnValue::Integer(1)),
2080 ],
2081 1,
2082 )
2083 .unwrap();
2084 client.rebuild_overlay();
2085 let first = client
2086 .patch(
2087 "tasks",
2088 "t1",
2089 Map::from_iter([
2090 ("starts_at_ms".into(), json!(200)),
2091 ("version".into(), json!(2)),
2092 ]),
2093 None,
2094 )
2095 .unwrap();
2096 let (_, first_request) = client.build_request(false);
2097 let second = client
2099 .patch(
2100 "tasks",
2101 "t1",
2102 Map::from_iter([
2103 ("starts_at_ms".into(), json!(300)),
2104 ("version".into(), json!(3)),
2105 ]),
2106 None,
2107 )
2108 .unwrap();
2109 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2110 for (id, seq, meta) in [
2111 (first, 2, first_request),
2112 (second, 3, client.build_request(false).1),
2113 ] {
2114 let response = Message {
2115 wire_version: WIRE_VERSION,
2116 msg_kind: MsgKind::Response,
2117 frames: vec![
2118 Frame::RespHeader {
2119 required_schema_version: None,
2120 latest_schema_version: None,
2121 log_epoch: Some("epoch-1".into()),
2122 reset_required: Some(false),
2123 },
2124 Frame::PushResult {
2125 client_commit_id: id,
2126 status: PushStatus::Applied,
2127 commit_seq: Some(seq),
2128 results: vec![OpResult::Applied { op_index: 0 }],
2129 },
2130 ],
2131 };
2132 assert!(matches!(
2133 client.process_response(&mut transport, response, &meta),
2134 SyncOutcome::Ok(_)
2135 ));
2136 let row = &client
2137 .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
2138 .unwrap()[0];
2139 assert_eq!(
2140 row["starts_at_ms"],
2141 json!(300),
2142 "an applied ACK without a pull image must not expose the old base"
2143 );
2144 assert_eq!(
2145 row["version"],
2146 json!(3),
2147 "the queued domain version must remain monotonic"
2148 );
2149 }
2150 }
2151
2152 #[test]
2153 fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
2154 for stored in [None, Some("epoch-old")] {
2155 let mut client = client();
2156 client
2157 .subscribe(
2158 "tasks".into(),
2159 "tasks".into(),
2160 vec![("project_id".into(), vec!["p1".into()])],
2161 None,
2162 )
2163 .unwrap();
2164 client
2165 .conn
2166 .execute_batch(
2167 "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
2168 )
2169 .unwrap();
2170 let id = client
2171 .mutate(vec![Mutation::Upsert {
2172 table: "tasks".into(),
2173 values: Map::from_iter([
2174 ("id".into(), json!("offline")),
2175 ("project_id".into(), json!("p1")),
2176 ]),
2177 base_version: None,
2178 }])
2179 .unwrap();
2180 if let Some(epoch) = stored {
2181 client.set_meta(LOG_EPOCH_KEY, epoch);
2182 }
2183 let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
2184 let (_, meta) = client.build_request(false);
2185 client.drain_change_batches();
2186 client.drain_sync_intents();
2187 let response = Message {
2188 wire_version: WIRE_VERSION,
2189 msg_kind: MsgKind::Response,
2190 frames: vec![Frame::RespHeader {
2191 required_schema_version: None,
2192 latest_schema_version: None,
2193 log_epoch: Some("epoch-new".into()),
2194 reset_required: Some(true),
2195 }],
2196 };
2197 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2198 let outcome = client.process_response(&mut transport, response, &meta);
2199 let SyncOutcome::Ok(report) = outcome else {
2200 panic!("epoch round failed: {outcome:?}");
2201 };
2202 assert_eq!(
2203 report.resets,
2204 if stored.is_none() {
2205 Vec::<String>::new()
2206 } else {
2207 vec!["tasks".into()]
2208 }
2209 );
2210 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
2211 assert_eq!(client.pending_commit_ids(), vec![id]);
2212 assert_eq!(
2213 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2214 json!("offline")
2215 );
2216 let trigger: i64 = client
2217 .conn
2218 .query_row(
2219 "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
2220 [],
2221 |row| row.get(0),
2222 )
2223 .unwrap();
2224 if stored.is_none() {
2225 assert!(!client.upgrading);
2226 assert!(client
2227 .drain_change_batches()
2228 .iter()
2229 .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
2230 assert_eq!(
2231 serde_json::to_value(client.subscription_state("tasks")).unwrap(),
2232 before
2233 );
2234 assert_eq!(trigger, 1, "acquisition must not drop the synced table");
2235 } else {
2236 assert!(client.upgrading);
2237 assert_eq!(trigger, 0, "an epoch change must drop the old table");
2238 }
2239 assert!(matches!(
2240 client.drain_sync_intents().as_slice(),
2241 [SyncIntent::Interactive]
2242 ));
2243 }
2244 }
2245
2246 #[test]
2247 fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
2248 for key in ["logEpoch", "localRevision"] {
2249 let mut client = client();
2250 let id = client
2251 .mutate(vec![Mutation::Upsert {
2252 table: "tasks".into(),
2253 values: Map::from_iter([
2254 ("id".into(), json!("offline")),
2255 ("project_id".into(), json!("p1")),
2256 ]),
2257 base_version: None,
2258 }])
2259 .unwrap();
2260 let revision = client.local_revision();
2261 let needed = client.sync_needed;
2262 client.drain_change_batches();
2263 client.drain_sync_intents();
2264 client.conn.execute_batch(&format!("CREATE TRIGGER fail_acquisition BEFORE INSERT ON _syncular_meta WHEN NEW.key='{key}' BEGIN SELECT RAISE(FAIL,'injected acquisition failure'); END")).unwrap();
2265 assert!(client.run_log_epoch_reset("epoch-new").is_err());
2266 assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
2267 assert!(!client.upgrading);
2268 assert_eq!(client.sync_needed, needed);
2269 assert_eq!(client.local_revision(), revision);
2270 assert_eq!(client.pending_commit_ids(), [id]);
2271 assert!(client.drain_change_batches().is_empty());
2272 assert!(client.drain_sync_intents().is_empty());
2273 }
2274 }
2275
2276 #[test]
2277 fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
2278 for subscriptions in [0, 2, 6, 12] {
2279 let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
2280 { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
2281 { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
2282 ] }), ClientLimits::default()).unwrap();
2283 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2284 client.conn.execute_batch("CREATE TABLE ack_writes(kind TEXT); CREATE TRIGGER count_ack_restore AFTER DELETE ON tasks BEGIN INSERT INTO ack_writes VALUES('restore'); END; CREATE TRIGGER count_ack_replay AFTER INSERT ON tasks BEGIN INSERT INTO ack_writes VALUES('replay'); END;").unwrap();
2285 for index in 0..32 {
2286 client
2287 .mutate(vec![Mutation::Upsert {
2288 table: "tasks".into(),
2289 values: Map::from_iter([
2290 ("id".into(), json!(format!("task-{index}"))),
2291 ("project_id".into(), json!("p1")),
2292 ]),
2293 base_version: None,
2294 }])
2295 .unwrap();
2296 }
2297 let (_, meta) = client.build_request(false);
2298 let mut frames = vec![Frame::RespHeader {
2299 required_schema_version: None,
2300 latest_schema_version: None,
2301 log_epoch: Some("epoch-1".into()),
2302 reset_required: Some(false),
2303 }];
2304 frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
2305 Frame::PushResult {
2306 client_commit_id: id.clone(),
2307 status: PushStatus::Applied,
2308 commit_seq: Some(index as i64 + 1),
2309 results: vec![OpResult::Applied { op_index: 0 }],
2310 }
2311 }));
2312 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2313 client.acknowledged_replay_count.set(0);
2314 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
2315 assert!(matches!(
2316 client.process_response(
2317 &mut transport,
2318 Message {
2319 wire_version: WIRE_VERSION,
2320 msg_kind: MsgKind::Response,
2321 frames
2322 },
2323 &meta
2324 ),
2325 SyncOutcome::Ok(_)
2326 ));
2327 assert_eq!(client.acknowledged_replay_count.get(), 32);
2328 assert_eq!(
2329 client
2330 .query(
2331 "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
2332 &[]
2333 )
2334 .unwrap()[0]["n"],
2335 json!(32)
2336 );
2337 assert_eq!(
2338 client
2339 .query(
2340 "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
2341 &[]
2342 )
2343 .unwrap()[0]["n"],
2344 json!(32)
2345 );
2346 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
2347 client.acknowledged_replay_count.set(0);
2348 for index in 0..subscriptions {
2349 client
2350 .subscribe(
2351 format!("docs-{index}"),
2352 "docs".into(),
2353 vec![("project_id".into(), vec![format!("scope-{index}")])],
2354 None,
2355 )
2356 .unwrap();
2357 let sub = client.subs.len() - 1;
2358 client.subs[sub].effective =
2359 Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
2360 let segment = RowsSegment {
2361 table: "docs".into(),
2362 schema_version: 1,
2363 columns: client.schema.table("docs").unwrap().wire_columns.clone(),
2364 blocks: if index % 2 == 0 {
2365 vec![vec![ssp2::segment::SegmentRow {
2366 values: vec![
2367 Some(ColumnValue::String(format!("doc-{index}"))),
2368 Some(ColumnValue::String(format!("scope-{index}"))),
2369 ],
2370 server_version: 1,
2371 }]]
2372 } else {
2373 vec![]
2374 },
2375 };
2376 assert!(client.apply_segment(sub, &segment, true).is_ok());
2377 }
2378 assert_eq!(
2379 client
2380 .query("SELECT count(*) AS n FROM ack_writes", &[])
2381 .unwrap()[0]["n"],
2382 json!(0),
2383 "unrelated bootstrap must not restore/replay tasks"
2384 );
2385 assert_eq!(
2386 client.acknowledged_replay_count.get(),
2387 0,
2388 "unrelated bootstrap must not decode task ACKs"
2389 );
2390 assert_eq!(
2391 client
2392 .query("SELECT count(*) AS n FROM tasks", &[])
2393 .unwrap()[0]["n"],
2394 json!(32)
2395 );
2396 assert_eq!(
2397 client
2398 .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
2399 .unwrap()[0]["n"],
2400 json!(32)
2401 );
2402 }
2403 }
2404
2405 #[test]
2406 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
2407 let mut client = client();
2408 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2409 const COMMIT_COUNT: usize = 32;
2410
2411 for index in 0..COMMIT_COUNT {
2412 client
2413 .mutate(vec![Mutation::Upsert {
2414 table: "tasks".to_owned(),
2415 values: Map::from_iter([
2416 ("id".to_owned(), Value::from(format!("task-{index}"))),
2417 ("project_id".to_owned(), Value::from("project-1")),
2418 ]),
2419 base_version: None,
2420 }])
2421 .expect("queue commit");
2422 }
2423
2424 let (_, request_meta) = client.build_request(false);
2425 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
2426 let mut frames = vec![Frame::RespHeader {
2427 required_schema_version: None,
2428 latest_schema_version: None,
2429 log_epoch: Some("epoch-1".to_owned()),
2430 reset_required: Some(false),
2431 }];
2432 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
2433 |(index, client_commit_id)| Frame::PushResult {
2434 client_commit_id: client_commit_id.clone(),
2435 status: PushStatus::Applied,
2436 commit_seq: Some(index as i64 + 1),
2437 results: vec![OpResult::Applied { op_index: 0 }],
2438 },
2439 ));
2440 let response = Message {
2441 wire_version: WIRE_VERSION,
2442 msg_kind: MsgKind::Response,
2443 frames,
2444 };
2445 let mut transport =
2446 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2447
2448 client.overlay_rebuild_count.set(0);
2449 client.outcome_prune_count.set(0);
2450 let outcome = client.process_response(&mut transport, response, &request_meta);
2451 assert!(matches!(outcome, SyncOutcome::Ok(_)));
2452 assert!(client.pending_commit_ids().is_empty());
2453 assert_eq!(
2454 client.overlay_rebuild_count.get(),
2455 1,
2456 "one response must reconcile its acknowledged commits with one overlay rebuild"
2457 );
2458 assert_eq!(
2459 client.outcome_prune_count.get(),
2460 1,
2461 "one response must enforce outcome retention once"
2462 );
2463 }
2464
2465 #[test]
2466 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
2467 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
2468 "columns": [
2469 {"name":"id", "type":"string", "nullable":false},
2470 {"name":"project_id", "type":"string", "nullable":false},
2471 {"name":"title", "type":"string", "nullable":false}
2472 ], "scopes": [{"pattern":"project:{project_id}"}],
2473 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2474 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
2475 }] });
2476 let mut incremental =
2477 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
2478 let mut reference =
2479 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
2480 let tables = vec!["tasks".to_owned()];
2481 for index in 0..32 {
2482 let id = format!("task-{}", index % 8);
2483 let values = Map::from_iter([
2484 ("id".to_owned(), Value::from(id.clone())),
2485 (
2486 "project_id".to_owned(),
2487 Value::from(format!("p{}", index % 2)),
2488 ),
2489 ("title".to_owned(), Value::from(format!("needle{index}"))),
2490 ]);
2491 let payload = encode_row_json(
2492 incremental.schema.table("tasks").unwrap(),
2493 &id,
2494 &values,
2495 &incremental.encryption,
2496 )
2497 .unwrap();
2498 let change = ssp2::model::Change {
2499 table_index: 0,
2500 row_id: id,
2501 op: if index % 5 == 0 {
2502 Op::Delete
2503 } else {
2504 Op::Upsert
2505 },
2506 row_version: Some(index + 1),
2507 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
2508 row: Some(payload),
2509 };
2510 reference.overlay_dirty.set(true);
2511 for instance in [&mut incremental, &mut reference] {
2512 instance.begin_observation("mirror_test").unwrap();
2513 let mut batch = ChangeAccumulator::default();
2514 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
2515 instance
2516 .apply_commit_changes(&tables, std::slice::from_ref(&change))
2517 .unwrap();
2518 instance.rebuild_overlay_if_dirty();
2519 instance.finish_observation("mirror_test", batch).unwrap();
2520 }
2521 for sql in [
2522 "SELECT * FROM tasks ORDER BY id",
2523 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2524 ] {
2525 assert_eq!(
2526 incremental.query(sql, &[]).unwrap(),
2527 reference.query(sql, &[]).unwrap()
2528 );
2529 }
2530 assert_eq!(incremental.local_revision(), reference.local_revision());
2531 assert_eq!(
2532 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2533 serde_json::to_value(reference.drain_change_batches()).unwrap()
2534 );
2535 }
2536 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2537 assert_eq!(reference.overlay_rebuild_count.get(), 32);
2538 }
2539
2540 #[test]
2541 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
2542 let cases = [
2543 (
2544 "string",
2545 vec![
2546 json!("1"),
2547 json!("01"),
2548 json!(""),
2549 json!("nul\u{0}id"),
2550 json!("é"),
2551 ],
2552 ),
2553 (
2554 "integer",
2555 vec![
2556 json!(i64::MIN),
2557 json!(-1),
2558 json!(0),
2559 json!(1),
2560 json!(i64::MAX),
2561 ],
2562 ),
2563 ("boolean", vec![json!(false), json!(true)]),
2564 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
2565 ];
2566 for (kind, ids) in cases {
2567 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2568 "columns":[{"name":"id","type":kind,"nullable":false},
2569 {"name":"project_id","type":"string","nullable":false}],
2570 "scopes":[{"pattern":"project:{project_id}"}]}]});
2571 let mut client =
2572 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
2573 for (index, id) in ids.iter().enumerate() {
2574 let table = client.schema.table("tasks").unwrap();
2575 client
2576 .write_base_row(
2577 "tasks",
2578 &vec![
2579 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
2580 Some(ColumnValue::String(format!("p{index}"))),
2581 ],
2582 1,
2583 )
2584 .unwrap();
2585 }
2586 client.rebuild_overlay_if_dirty();
2587 for base in [true, false] {
2588 let full = if base {
2589 base_table("tasks")
2590 } else {
2591 visible_table("tasks")
2592 };
2593 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
2594 for row_id in [
2595 "1",
2596 "01",
2597 "1.0",
2598 "1.0000000000000002",
2599 "+1",
2600 "1e0",
2601 " 1",
2602 "-1",
2603 "0",
2604 "9223372036854775807",
2605 "-9223372036854775808",
2606 "true",
2607 "false",
2608 "",
2609 "nul\u{0}id",
2610 "é",
2611 "null",
2612 "{\"a\":1}",
2613 ] {
2614 let old_sql =
2615 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
2616 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
2617 let old = client
2618 .conn
2619 .prepare(&old_sql)
2620 .unwrap()
2621 .query_map([row_id], |row| row.get::<_, String>(0))
2622 .unwrap()
2623 .collect::<Result<Vec<_>, _>>()
2624 .unwrap();
2625 let new = client
2626 .conn
2627 .prepare_cached(&new_sql)
2628 .unwrap()
2629 .query_map([row_id], |row| row.get::<_, String>(0))
2630 .unwrap()
2631 .collect::<Result<Vec<_>, _>>()
2632 .unwrap();
2633 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
2634 let mut batch = ChangeAccumulator::default();
2635 assert_eq!(
2636 client.record_row_scopes(&mut batch, "tasks", row_id, base),
2637 !old.is_empty()
2638 );
2639 let expected = old.first().map(|scope| {
2640 BTreeMap::from([(
2641 "tasks".to_owned(),
2642 Some(BTreeSet::from([format!("project:{scope}")])),
2643 )])
2644 });
2645 assert_eq!(batch.tables, expected.unwrap_or_default());
2646 client
2647 .conn
2648 .execute_batch("SAVEPOINT lookup_delete")
2649 .unwrap();
2650 if base {
2651 client.delete_base_row("tasks", row_id).unwrap();
2652 } else {
2653 client.apply_outbox_ops(&[OutboxOp {
2654 upsert: false,
2655 table: "tasks".into(),
2656 row_id: row_id.into(),
2657 base_version: None,
2658 values: None,
2659 }]);
2660 }
2661 let remaining: i64 = client
2662 .conn
2663 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2664 row.get(0)
2665 })
2666 .unwrap();
2667 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2668 client
2669 .conn
2670 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2671 .unwrap();
2672 client.overlay_dirty.set(false);
2673 }
2674 for (predicate, expected) in [
2675 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2676 (predicate, "SEARCH"),
2677 ] {
2678 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2679 let details = client
2680 .conn
2681 .prepare(&sql)
2682 .unwrap()
2683 .query_map(["1"], |row| row.get::<_, String>(3))
2684 .unwrap()
2685 .collect::<Result<Vec<_>, _>>()
2686 .unwrap();
2687 assert!(
2688 details.iter().any(|detail| detail.contains(expected)),
2689 "{kind}: {details:?}"
2690 );
2691 }
2692 }
2693 }
2694 }
2695
2696 #[test]
2697 fn primary_keys_reject_every_ineligible_column_type() {
2698 for ty in ["float", "bytes", "crdt", "blob_ref"] {
2702 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2703 "columns":[{"name":"id","type":ty,"nullable":false},
2704 {"name":"project_id","type":"string","nullable":false}],
2705 "scopes":[{"pattern":"project:{project_id}"}]}]});
2706 let error =
2707 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2708 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2709 Err(error) => error,
2710 };
2711 assert!(
2712 error.contains("has an ineligible column type (§2.4)"),
2713 "{ty}: {error}"
2714 );
2715 }
2716 for ty in ["string", "integer", "boolean", "json"] {
2717 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2718 "columns":[{"name":"id","type":ty,"nullable":false},
2719 {"name":"project_id","type":"string","nullable":false}],
2720 "scopes":[{"pattern":"project:{project_id}"}]}]});
2721 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2722 .unwrap_or_else(|error| panic!("{ty}: {error}"));
2723 }
2724 }
2725
2726 #[test]
2727 fn pending_row_reconciliation_matches_full_fifo_replay() {
2728 for unique in [false, true] {
2729 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2730 "name": name, "primaryKey": "id", "columns": [
2731 {"name":"id", "type":"string", "nullable":false},
2732 {"name":"project_id", "type":"string", "nullable":false},
2733 {"name":"title", "type":"string", "nullable":false}],
2734 "scopes": [{"pattern":"project:{project_id}"}],
2735 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2736 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2737 })))});
2738 let mut incremental =
2739 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2740 let mut reference =
2741 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2742 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2743 for instance in [&mut incremental, &mut reference] {
2744 for table in &tables {
2745 for index in 0..12 {
2746 instance
2747 .write_base_row(
2748 table,
2749 &vec![
2750 Some(ColumnValue::String(format!("row{index}"))),
2751 Some(ColumnValue::String("p0".into())),
2752 Some(ColumnValue::String(format!("base{index}"))),
2753 ],
2754 1,
2755 )
2756 .unwrap();
2757 }
2758 }
2759 instance.rebuild_overlay_if_dirty();
2760 }
2761 for index in 0..80 {
2762 let mutations = (0..3)
2763 .map(|offset| {
2764 let table = tables[(index + offset) % 2].clone();
2765 let id = format!("row{}", (index + offset) % 16);
2766 if (index + offset) % 7 == 0 {
2767 Mutation::Delete {
2768 table,
2769 row_id: id,
2770 base_version: None,
2771 }
2772 } else {
2773 Mutation::Upsert {
2774 table,
2775 values: Map::from_iter([
2776 ("id".into(), Value::from(id)),
2777 ("project_id".into(), Value::from(format!("p{}", index % 3))),
2778 (
2779 "title".into(),
2780 Value::from(format!("needle{}", (index + offset) % 16)),
2781 ),
2782 ]),
2783 base_version: None,
2784 }
2785 }
2786 })
2787 .collect::<Vec<_>>();
2788 for instance in [&mut incremental, &mut reference] {
2789 instance.mutate(mutations.clone()).unwrap();
2790 }
2791 let changes = (0..3)
2794 .map(|offset| {
2795 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2796 let id = format!("row{}", (index + offset) % 16);
2797 let values = Map::from_iter([
2798 ("id".into(), Value::from(id.clone())),
2799 (
2800 "project_id".into(),
2801 Value::from(format!("p{}", (index + 1) % 3)),
2802 ),
2803 (
2804 "title".into(),
2805 Value::from(format!("remote{index}_{offset}")),
2806 ),
2807 ]);
2808 ssp2::model::Change {
2809 table_index: table_index as u16,
2810 row_id: id.clone(),
2811 op: if (index + offset) % 5 == 0 {
2812 Op::Delete
2813 } else {
2814 Op::Upsert
2815 },
2816 row_version: Some(index as i64 + 2),
2817 scopes: vec![],
2818 row: Some(
2819 encode_row_json(
2820 incremental.schema.table(&tables[table_index]).unwrap(),
2821 &id,
2822 &values,
2823 &incremental.encryption,
2824 )
2825 .unwrap(),
2826 ),
2827 }
2828 })
2829 .collect::<Vec<_>>();
2830 reference.overlay_dirty.set(true);
2831 incremental
2832 .apply_commit_frame(&tables, &changes, None)
2833 .unwrap();
2834 reference
2835 .apply_commit_frame(&tables, &changes, None)
2836 .unwrap();
2837 for table in &tables {
2838 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2839 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2840 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2841 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2842 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2843 }
2844 }
2845 assert_eq!(incremental.local_revision(), reference.local_revision());
2846 assert_eq!(
2847 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2848 serde_json::to_value(reference.drain_change_batches()).unwrap()
2849 );
2850 }
2851 assert_eq!(
2852 incremental.overlay_rebuild_count.get(),
2853 if unique { 81 } else { 21 }
2854 );
2855 assert_eq!(reference.overlay_rebuild_count.get(), 81);
2856 }
2857 }
2858
2859 #[test]
2860 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2861 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2862 "columns":[{"name":"id", "type":"string", "nullable":false},
2863 {"name":"title", "type":"string", "nullable":false}],
2864 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2865 let mut client =
2866 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2867 for (id, title) in [("a", "original"), ("b", "occupied")] {
2868 client
2869 .write_base_row(
2870 "tasks",
2871 &vec![
2872 Some(ColumnValue::String(id.into())),
2873 Some(ColumnValue::String(title.into())),
2874 ],
2875 1,
2876 )
2877 .unwrap();
2878 }
2879 client.rebuild_overlay_if_dirty();
2880 let pending = client
2881 .mutate(vec![Mutation::Upsert {
2882 table: "tasks".into(),
2883 values: Map::from_iter([
2884 ("id".into(), Value::from("a")),
2885 ("title".into(), Value::from("available")),
2886 ]),
2887 base_version: None,
2888 }])
2889 .unwrap();
2890 assert_eq!(
2891 client
2892 .conn
2893 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2894 .get::<_, String>(
2895 0
2896 ))
2897 .unwrap(),
2898 "available"
2899 );
2900 for (title, expected) in [("available", "original"), ("occupied", "available")] {
2901 let change = ssp2::model::Change {
2902 table_index: 0,
2903 row_id: "b".into(),
2904 op: Op::Upsert,
2905 row_version: Some(2),
2906 scopes: vec![],
2907 row: Some(
2908 encode_row_json(
2909 client.schema.table("tasks").unwrap(),
2910 "b",
2911 &Map::from_iter([
2912 ("id".into(), Value::from("b")),
2913 ("title".into(), Value::from(title)),
2914 ]),
2915 &client.encryption,
2916 )
2917 .unwrap(),
2918 ),
2919 };
2920 client
2921 .apply_commit_frame(&["tasks".into()], &[change], None)
2922 .unwrap();
2923 assert_eq!(
2924 client
2925 .conn
2926 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2927 .get::<_, String>(
2928 0
2929 ))
2930 .unwrap(),
2931 expected
2932 );
2933 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2934 }
2935 assert_eq!(client.overlay_rebuild_count.get(), 3);
2936 }
2937
2938 fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
2939 let schema = json!({ "version": 1, "tables": [
2940 { "name":"tasks", "primaryKey":"id", "columns":[
2941 {"name":"id","type":"string","nullable":false},
2942 {"name":"project_id","type":"string","nullable":false}],
2943 "scopes":[{"pattern":"project:{project_id}"}] },
2944 { "name":"catalogue", "primaryKey":"id", "columns":[
2945 {"name":"id","type":"string","nullable":false},
2946 {"name":"project_id","type":"string","nullable":false},
2947 {"name":"title","type":"string","nullable":false}],
2948 "scopes":[{"pattern":"project:{project_id}"}],
2949 "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
2950 ] });
2951 let mut client =
2952 SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
2953 if unchanged_rows > 0 {
2954 client.conn.execute_batch(&format!("WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i<{unchanged_rows}) INSERT INTO _syncular_base_catalogue SELECT 'c'||i,'p1','needle '||i,1 FROM n")).unwrap();
2955 }
2956 client.rebuild_overlay();
2957 client.conn.execute_batch("CREATE TABLE audit(tbl TEXT); CREATE TRIGGER catalogue_changed AFTER DELETE ON catalogue BEGIN INSERT INTO audit VALUES ('catalogue'); END;").unwrap();
2958 let mut elapsed = Vec::new();
2959 for round in 1..=rounds {
2960 let pending = client
2961 .mutate(vec![Mutation::Upsert {
2962 table: "tasks".into(),
2963 values: Map::from_iter([
2964 ("id".into(), json!("t1")),
2965 ("project_id".into(), json!("p1")),
2966 ]),
2967 base_version: None,
2968 }])
2969 .unwrap();
2970 let started = std::time::Instant::now();
2971 client
2972 .write_base_row(
2973 "tasks",
2974 &vec![
2975 Some(ColumnValue::String("t1".into())),
2976 Some(ColumnValue::String("p1".into())),
2977 ],
2978 round,
2979 )
2980 .unwrap();
2981 let mut report = SyncReport::default();
2982 client
2983 .handle_push_results(
2984 &[Frame::PushResult {
2985 client_commit_id: pending.clone(),
2986 status: PushStatus::Applied,
2987 commit_seq: Some(round),
2988 results: vec![OpResult::Applied { op_index: 0 }],
2989 }],
2990 &HashSet::from([pending.as_str()]),
2991 &HashMap::new(),
2992 &mut report,
2993 Some(&pending),
2994 )
2995 .unwrap();
2996 client.rebuild_overlay_if_dirty();
2997 elapsed.push(started.elapsed().as_nanos());
2998 }
2999 assert_eq!(
3000 client
3001 .conn
3002 .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
3003 .unwrap(),
3004 0,
3005 "unchanged catalogue must never be copied"
3006 );
3007 assert_eq!(
3008 client
3009 .conn
3010 .query_row(
3011 "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
3012 [],
3013 |r| r.get::<_, i64>(0)
3014 )
3015 .unwrap(),
3016 unchanged_rows
3017 );
3018 assert_eq!(
3019 client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
3020 json!(rounds)
3021 );
3022 elapsed
3023 }
3024
3025 #[test]
3026 fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
3027 let elapsed = measure_scoped_overlay(100_000, 1);
3028 eprintln!(
3029 "table-scoped overlay with 100k unchanged rows: {} ns",
3030 elapsed[0]
3031 );
3032 }
3033
3034 #[test]
3035 #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
3036 fn benchmark_scoped_overlay() {
3037 for rows in [0, 100_000, 200_000] {
3038 let mut elapsed = measure_scoped_overlay(rows, 30);
3039 elapsed.sort_unstable();
3040 eprintln!(
3041 "{}",
3042 json!({"unchangedRows": rows, "rounds": elapsed.len(),
3043 "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
3044 "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
3045 );
3046 }
3047 }
3048
3049 #[test]
3050 fn released_context_discards_in_flight_ack_and_server_rows() {
3051 for barrier in ["preflight", "purge", "subscription"] {
3052 let mut client = client();
3053 client.set_meta(LOG_EPOCH_KEY, "epoch");
3054 client
3055 .subscribe(
3056 "tasks".into(),
3057 "tasks".into(),
3058 vec![("project_id".into(), vec!["p1".into()])],
3059 None,
3060 )
3061 .unwrap();
3062 let commit = client
3063 .mutate(vec![Mutation::Upsert {
3064 table: "tasks".into(),
3065 values: Map::from_iter([
3066 ("id".into(), json!("local")),
3067 ("project_id".into(), json!("p1")),
3068 ]),
3069 base_version: None,
3070 }])
3071 .unwrap();
3072 let prepared = client.prepare_sync_round(false).unwrap();
3073 client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
3074 let payload = encode_row_json(
3075 &client.schema.tables[0],
3076 "server",
3077 &Map::from_iter([
3078 ("id".into(), json!("server")),
3079 ("project_id".into(), json!("p1")),
3080 ]),
3081 &client.encryption,
3082 )
3083 .unwrap();
3084 let completed = crate::CompletedSyncRound {
3085 prepared,
3086 exchange: crate::round::ExchangeResult::Reply {
3087 transport_failed: false,
3088 downloads: Default::default(),
3089 response: Ok(Message {
3090 wire_version: WIRE_VERSION,
3091 msg_kind: MsgKind::Response,
3092 frames: vec![
3093 Frame::RespHeader {
3094 required_schema_version: None,
3095 latest_schema_version: None,
3096 log_epoch: Some("epoch".into()),
3097 reset_required: Some(false),
3098 },
3099 Frame::PushResult {
3100 client_commit_id: commit.clone(),
3101 status: PushStatus::Applied,
3102 commit_seq: Some(1),
3103 results: vec![OpResult::Applied { op_index: 0 }],
3104 },
3105 Frame::SubStart {
3106 id: "tasks".into(),
3107 status: SubStatus::Active,
3108 reason_code: String::new(),
3109 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
3110 bootstrap: true,
3111 },
3112 Frame::Commit {
3113 commit_seq: 1,
3114 created_at_ms: 0,
3115 actor_id: "actor".into(),
3116 tables: vec!["tasks".into()],
3117 changes: vec![ssp2::model::Change {
3118 table_index: 0,
3119 row_id: "server".into(),
3120 op: Op::Upsert,
3121 row_version: Some(1),
3122 scopes: vec![("project_id".into(), "p1".into())],
3123 row: Some(payload),
3124 }],
3125 },
3126 Frame::SubEnd {
3127 next_cursor: 1,
3128 bootstrap_state: None,
3129 },
3130 ],
3131 }),
3132 },
3133 };
3134 match barrier {
3135 "preflight" => client.begin_security_preflight(),
3136 "purge" => {
3137 client
3138 .purge_local_data(&LocalDataPurgeInput {
3139 purge_id: "pending-round".into(),
3140 targets: vec![LocalDataPurgeTarget {
3141 table: "tasks".into(),
3142 selectors: BTreeMap::from([(
3143 "project_id".into(),
3144 vec!["p1".into()],
3145 )]),
3146 }],
3147 })
3148 .unwrap();
3149 }
3150 "subscription" => {
3151 client.unsubscribe("tasks");
3152 client
3153 .subscribe(
3154 "tasks".into(),
3155 "tasks".into(),
3156 vec![("project_id".into(), vec!["p2".into()])],
3157 None,
3158 )
3159 .unwrap();
3160 }
3161 _ => unreachable!(),
3162 }
3163 let pending_before_reply = client.pending_commit_ids();
3164 let revision = client.local_revision();
3165 let applied = client.apply_sync_round(completed);
3166 assert!(
3167 matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
3168 );
3169 assert_eq!(client.local_revision(), revision);
3170 assert!(client
3171 .conn
3172 .query_row(
3173 "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
3174 [],
3175 |r| r.get::<_, bool>(0)
3176 )
3177 .unwrap());
3178 assert_eq!(client.pending_commit_ids(), pending_before_reply);
3179 }
3180 }
3181
3182 #[test]
3183 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
3184 for failure in ["visible", "revision", "commit"] {
3185 let mut client = client();
3186 let id = client
3187 .mutate(vec![Mutation::Upsert {
3188 table: "tasks".into(),
3189 values: Map::from_iter([
3190 ("id".into(), Value::from("t1")),
3191 ("project_id".into(), Value::from("pending")),
3192 ]),
3193 base_version: None,
3194 }])
3195 .unwrap();
3196 client.drain_change_batches();
3197 let revision = client.local_revision();
3198 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
3199 let trigger = match failure {
3200 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
3201 "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"),
3202 _ => "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(),
3203 };
3204 client.conn.execute_batch(&trigger).unwrap();
3205 let change = ssp2::model::Change {
3206 table_index: 0,
3207 row_id: "t1".into(),
3208 op: Op::Upsert,
3209 row_version: Some(2),
3210 scopes: vec![],
3211 row: Some(
3212 encode_row_json(
3213 client.schema.table("tasks").unwrap(),
3214 "t1",
3215 &Map::from_iter([
3216 ("id".into(), Value::from("t1")),
3217 ("project_id".into(), Value::from("remote")),
3218 ]),
3219 &client.encryption,
3220 )
3221 .unwrap(),
3222 ),
3223 };
3224 assert!(client
3225 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
3226 .is_err());
3227 assert!(client.conn.is_autocommit());
3228 assert!(!client.overlay_dirty.get());
3229 assert_eq!(client.local_revision(), revision);
3230 assert!(client.drain_change_batches().is_empty());
3231 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
3232 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
3233 assert!(client
3234 .query("SELECT * FROM _syncular_base_tasks", &[])
3235 .unwrap()
3236 .is_empty());
3237 client
3238 .conn
3239 .execute_batch("DROP TRIGGER fail_pending")
3240 .unwrap();
3241 client
3242 .apply_commit_frame(&["tasks".into()], &[change], None)
3243 .unwrap();
3244 assert_eq!(client.local_revision(), revision + 1);
3245 assert_eq!(client.pending_commit_ids(), vec![id]);
3246 let mut expected = before[0].clone();
3249 expected.insert("_syncular_version".to_owned(), Value::from(2));
3250 assert_eq!(
3251 client.query("SELECT * FROM tasks", &[]).unwrap(),
3252 vec![expected]
3253 );
3254 assert_eq!(
3255 client
3256 .conn
3257 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
3258 .get::<_, String>(0))
3259 .unwrap(),
3260 "remote"
3261 );
3262 }
3263 }
3264
3265 #[test]
3266 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
3267 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
3268 "columns": [
3269 {"name":"id", "type":"string", "nullable":false},
3270 {"name":"project_id", "type":"string", "nullable":false},
3271 {"name":"title", "type":"string", "nullable":false}],
3272 "scopes": [{"pattern":"project:{project_id}"}],
3273 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
3274 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
3275 let mut incremental =
3276 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
3277 let mut reference =
3278 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
3279 for instance in [&mut incremental, &mut reference] {
3280 for index in 0..16 {
3281 instance
3282 .write_base_row(
3283 "tasks",
3284 &vec![
3285 Some(ColumnValue::String(format!("task{index}"))),
3286 Some(ColumnValue::String("p0".to_owned())),
3287 Some(ColumnValue::String(format!("base{index}"))),
3288 ],
3289 1,
3290 )
3291 .unwrap();
3292 }
3293 instance.rebuild_overlay_if_dirty();
3294 instance.overlay_rebuild_count.set(0);
3295 }
3296 for index in 0..100 {
3297 let mutations = (0..1 + index % 4)
3298 .map(|offset| {
3299 let id = format!("task{}", (index + offset) % 24);
3300 if (index + offset) % 7 == 0 {
3301 Mutation::Delete {
3302 table: "tasks".to_owned(),
3303 row_id: id,
3304 base_version: None,
3305 }
3306 } else {
3307 Mutation::Upsert {
3308 table: "tasks".to_owned(),
3309 values: Map::from_iter([
3310 ("id".to_owned(), Value::from(id)),
3311 (
3312 "project_id".to_owned(),
3313 Value::from(format!("p{}", index % 2)),
3314 ),
3315 (
3316 "title".to_owned(),
3317 Value::from(format!("needle{}", (index + offset) % 24)),
3318 ),
3319 ]),
3320 base_version: None,
3321 }
3322 }
3323 })
3324 .collect::<Vec<_>>();
3325 reference.overlay_dirty.set(true);
3326 incremental.mutate(mutations.clone()).unwrap();
3327 reference.mutate(mutations).unwrap();
3328 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
3329 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
3330 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
3331 }
3332 assert_eq!(incremental.local_revision(), reference.local_revision());
3333 assert_eq!(
3334 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
3335 serde_json::to_value(reference.drain_change_batches()).unwrap()
3336 );
3337 }
3338 assert_eq!(incremental.outbox.len(), 100);
3339 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
3340 assert_eq!(reference.overlay_rebuild_count.get(), 100);
3341 }
3342
3343 #[test]
3344 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
3345 for fail_at in ["outbox", "revision"] {
3346 let mut client = client();
3347 let values = Map::from_iter([
3348 ("id".to_owned(), Value::from("t1")),
3349 ("project_id".to_owned(), Value::from("p1")),
3350 ]);
3351 let mutation = Mutation::Upsert {
3352 table: "tasks".to_owned(),
3353 values,
3354 base_version: None,
3355 };
3356 let trigger = if fail_at == "outbox" {
3357 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
3358 } else {
3359 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
3360 };
3361 client.conn.execute_batch(&trigger).unwrap();
3362 let revision = client.local_revision();
3363 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
3364 assert_eq!(client.local_revision(), revision);
3365 assert!(client.outbox.is_empty());
3366 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
3367 assert_eq!(
3368 client
3369 .conn
3370 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
3371 .get::<_, i64>(0))
3372 .unwrap(),
3373 0
3374 );
3375 assert!(client.drain_change_batches().is_empty());
3376 assert!(!client.overlay_dirty.get());
3377 client
3378 .conn
3379 .execute_batch("DROP TRIGGER fail_append")
3380 .unwrap();
3381 client.mutate(vec![mutation]).unwrap();
3382 assert_eq!(client.outbox.len(), 1);
3383 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
3384 }
3385 }
3386
3387 #[test]
3388 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
3389 let mut client = client();
3390 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
3391 let values = Map::from_iter([
3392 ("id".to_owned(), Value::from("t1")),
3393 ("project_id".to_owned(), Value::from("p1")),
3394 ]);
3395 let payload = encode_row_json(
3396 client.schema.table("tasks").unwrap(),
3397 "t1",
3398 &values,
3399 &client.encryption,
3400 )
3401 .unwrap();
3402 let first = ssp2::model::Change {
3403 table_index: 0,
3404 row_id: "t1".to_owned(),
3405 op: Op::Upsert,
3406 row_version: Some(1),
3407 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
3408 row: Some(payload),
3409 };
3410 let mut invalid = first.clone();
3411 invalid.table_index = 1;
3412 let revision = client.local_revision();
3413 client.begin_observation("failed_mirror").unwrap();
3414 assert!(client
3415 .apply_commit_changes(&tables, &[first, invalid])
3416 .is_err());
3417 client.rollback_observation("failed_mirror");
3418 client.rebuild_overlay_if_dirty();
3419 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
3420 assert_eq!(
3421 client
3422 .conn
3423 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
3424 .get::<_, i64>(
3425 0
3426 ))
3427 .unwrap(),
3428 0
3429 );
3430 assert_eq!(client.local_revision(), revision);
3431 assert!(client.drain_change_batches().is_empty());
3432 }
3433
3434 #[test]
3435 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
3436 for failure in ["journal", "revision", "commit", "later-frame"] {
3437 let mut client = client();
3438 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3439 for id in ["first", "second"] {
3440 client
3441 .mutate(vec![Mutation::Upsert {
3442 table: "tasks".to_owned(),
3443 values: Map::from_iter([
3444 ("id".to_owned(), Value::from(id)),
3445 ("project_id".to_owned(), Value::from("p1")),
3446 ]),
3447 base_version: None,
3448 }])
3449 .unwrap();
3450 }
3451 let (_, meta) = client.build_request(false);
3452 client
3454 .mutate(vec![Mutation::Upsert {
3455 table: "tasks".to_owned(),
3456 values: Map::from_iter([
3457 ("id".to_owned(), Value::from("later")),
3458 ("project_id".to_owned(), Value::from("p1")),
3459 ]),
3460 base_version: None,
3461 }])
3462 .unwrap();
3463 let ids = client.pending_commit_ids();
3464 let rows = client
3465 .query("SELECT * FROM tasks ORDER BY id", &[])
3466 .unwrap();
3467 let revision = client.local_revision();
3468 client.drain_change_batches();
3469 if failure != "later-frame" {
3470 client.conn.execute_batch(match failure {
3471 "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",
3472 "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",
3473 _ => "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",
3474 }).unwrap();
3475 }
3476 let mut response = Message {
3477 wire_version: WIRE_VERSION,
3478 msg_kind: MsgKind::Response,
3479 frames: vec![Frame::RespHeader {
3480 required_schema_version: None,
3481 latest_schema_version: None,
3482 log_epoch: Some("epoch-1".to_owned()),
3483 reset_required: Some(false),
3484 }],
3485 };
3486 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
3487 response.frames.push(Frame::PushResult {
3488 client_commit_id: id.to_owned(),
3489 status: PushStatus::Applied,
3490 commit_seq: Some(1),
3491 results: vec![OpResult::Applied { op_index: 0 }],
3492 });
3493 }
3494 if failure == "later-frame" {
3495 response.frames.push(Frame::Error {
3496 code: "sync.invalid_request".to_owned(),
3497 message: "later error".to_owned(),
3498 category: "protocol".to_owned(),
3499 retryable: false,
3500 recommended_action: "retry".to_owned(),
3501 details: None,
3502 });
3503 }
3504 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3505 let outcome = client.process_response(&mut transport, response.clone(), &meta);
3506 assert!(
3507 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
3508 );
3509 if failure == "later-frame" {
3510 assert_eq!(client.pending_commit_ids(), ids[2..]);
3511 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
3512 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
3513 let batches = client.drain_change_batches();
3514 assert_eq!(batches.len(), 1);
3515 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
3516 assert_eq!(client.local_revision(), revision + 1);
3517 response.frames.pop();
3518 } else {
3519 assert_eq!(client.pending_commit_ids(), ids);
3520 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
3521 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
3522 assert_eq!(client.local_revision(), revision);
3523 assert!(client.drain_change_batches().is_empty());
3524 assert_eq!(
3525 client
3526 .query("SELECT * FROM tasks ORDER BY id", &[])
3527 .unwrap(),
3528 rows
3529 );
3530 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
3531 }
3532 for frame in &mut response.frames {
3533 if let Frame::PushResult { status, .. } = frame {
3534 *status = PushStatus::Cached;
3535 }
3536 }
3537 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
3538 else {
3539 panic!("retry failed");
3540 };
3541 assert_eq!(
3542 report.applied,
3543 if failure == "later-frame" {
3544 vec![]
3545 } else {
3546 ids[..2].to_vec()
3547 }
3548 );
3549 assert_eq!(client.pending_commit_ids(), ids[2..]);
3550 let batches = client.drain_change_batches();
3551 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
3552 if let Some(batch) = batches.first() {
3553 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
3554 }
3555 assert_eq!(client.local_revision(), revision + 1);
3556 }
3557 }
3558
3559 #[test]
3560 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
3561 for failure in ["revision", "journal", "commit"] {
3562 for status in ["applied", "cached", "rejected", "conflict"] {
3563 let mut client = client();
3564 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3565 for id in ["first", "later"] {
3566 client
3567 .mutate(vec![Mutation::Upsert {
3568 table: "tasks".to_owned(),
3569 values: Map::from_iter([
3570 ("id".to_owned(), Value::from(id)),
3571 ("project_id".to_owned(), Value::from("p1")),
3572 ]),
3573 base_version: None,
3574 }])
3575 .unwrap();
3576 }
3577 let (_, meta) = client.build_request(false);
3578 let ids = client.pending_commit_ids();
3579 let rows = client
3580 .query("SELECT * FROM tasks ORDER BY id", &[])
3581 .unwrap();
3582 let revision = client.local_revision();
3583 client.drain_change_batches();
3584 let (push_status, results) = match status {
3585 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
3586 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
3587 "conflict" => (
3588 PushStatus::Rejected,
3589 vec![OpResult::Conflict {
3590 op_index: 0,
3591 code: "sync.version_conflict".to_owned(),
3592 message: "conflict".to_owned(),
3593 server_version: 1,
3594 server_row: encode_row_json(
3595 client.schema.table("tasks").unwrap(),
3596 "first",
3597 &Map::from_iter([
3598 ("id".to_owned(), Value::from("first")),
3599 ("project_id".to_owned(), Value::from("p1")),
3600 ]),
3601 &client.encryption,
3602 )
3603 .unwrap(),
3604 conflict_columns: Vec::new(),
3605 }],
3606 ),
3607 _ => (
3608 PushStatus::Rejected,
3609 vec![OpResult::Error {
3610 op_index: 0,
3611 code: "sync.validation_failed".to_owned(),
3612 message: "rejected".to_owned(),
3613 retryable: false,
3614 }],
3615 ),
3616 };
3617 let response = Message {
3618 wire_version: WIRE_VERSION,
3619 msg_kind: MsgKind::Response,
3620 frames: vec![
3621 Frame::RespHeader {
3622 required_schema_version: None,
3623 latest_schema_version: None,
3624 log_epoch: Some("epoch-1".to_owned()),
3625 reset_required: Some(false),
3626 },
3627 Frame::PushResult {
3628 client_commit_id: ids[0].clone(),
3629 status: push_status,
3630 commit_seq: Some(1),
3631 results,
3632 },
3633 ],
3634 };
3635 client.conn.execute_batch(match failure {
3636 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
3637 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
3638 _ => "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",
3639 }).unwrap();
3640 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3641 let outcome = client.process_response(&mut transport, response.clone(), &meta);
3642 assert_eq!(
3643 client.pending_commit_ids(),
3644 ids,
3645 "{failure}/{status}: pending identity"
3646 );
3647 assert_eq!(
3648 client.local_revision(),
3649 revision,
3650 "{failure}/{status}: revision"
3651 );
3652 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
3653 assert!(client.conflicts().is_empty());
3654 assert!(client.rejections().is_empty());
3655 assert!(client.drain_change_batches().is_empty());
3656 assert_eq!(
3657 client
3658 .query("SELECT * FROM tasks ORDER BY id", &[])
3659 .unwrap(),
3660 rows
3661 );
3662 assert!(
3663 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
3664 "{failure}/{status}: failed round"
3665 );
3666 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
3667 assert!(matches!(
3668 client.process_response(&mut transport, response, &meta),
3669 SyncOutcome::Ok(_)
3670 ));
3671 assert_eq!(client.pending_commit_ids(), ids[1..]);
3672 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
3673 assert_eq!(client.drain_change_batches().len(), 1);
3674 }
3675 }
3676 }
3677
3678 #[test]
3679 fn mixed_push_results_reconcile_and_prune_once_per_response() {
3680 let mut client = client();
3681 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3682 for index in 0..4 {
3683 client
3684 .mutate(vec![Mutation::Upsert {
3685 table: "tasks".to_owned(),
3686 values: Map::from_iter([
3687 ("id".to_owned(), Value::from(format!("task-{index}"))),
3688 ("project_id".to_owned(), Value::from("project-1")),
3689 ]),
3690 base_version: None,
3691 }])
3692 .expect("queue commit");
3693 }
3694
3695 let (_, request_meta) = client.build_request(false);
3696 let ids = &request_meta.pushed_ids;
3697 assert_eq!(ids.len(), 4);
3698 let response = Message {
3699 wire_version: WIRE_VERSION,
3700 msg_kind: MsgKind::Response,
3701 frames: vec![
3702 Frame::RespHeader {
3703 required_schema_version: None,
3704 latest_schema_version: None,
3705 log_epoch: Some("epoch-1".to_owned()),
3706 reset_required: Some(false),
3707 },
3708 Frame::PushResult {
3709 client_commit_id: ids[0].clone(),
3710 status: PushStatus::Applied,
3711 commit_seq: Some(1),
3712 results: vec![OpResult::Applied { op_index: 0 }],
3713 },
3714 Frame::PushResult {
3715 client_commit_id: ids[1].clone(),
3716 status: PushStatus::Cached,
3717 commit_seq: Some(2),
3718 results: vec![OpResult::Applied { op_index: 0 }],
3719 },
3720 Frame::PushResult {
3721 client_commit_id: ids[2].clone(),
3722 status: PushStatus::Rejected,
3723 commit_seq: None,
3724 results: vec![OpResult::Error {
3725 op_index: 0,
3726 code: "sync.validation_failed".to_owned(),
3727 message: "rejected".to_owned(),
3728 retryable: false,
3729 }],
3730 },
3731 Frame::PushResult {
3732 client_commit_id: ids[3].clone(),
3733 status: PushStatus::Rejected,
3734 commit_seq: None,
3735 results: vec![OpResult::Error {
3736 op_index: 0,
3737 code: "sync.idempotency_cache_miss".to_owned(),
3738 message: "retry".to_owned(),
3739 retryable: true,
3740 }],
3741 },
3742 ],
3743 };
3744 let mut transport =
3745 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3746
3747 client.overlay_rebuild_count.set(0);
3748 client.outcome_prune_count.set(0);
3749 let outcome = client.process_response(&mut transport, response, &request_meta);
3750 let SyncOutcome::Ok(report) = outcome else {
3751 panic!("mixed push-result response failed");
3752 };
3753
3754 assert_eq!(report.applied, ids[..2]);
3755 assert_eq!(report.rejected, ids[2..3]);
3756 assert_eq!(report.retryable, ids[3..4]);
3757 assert_eq!(client.pending_commit_ids(), ids[3..4]);
3758 assert_eq!(
3759 client
3760 .query("SELECT id FROM tasks ORDER BY id", &[])
3761 .expect("query visible overlay"),
3762 ["task-0", "task-1", "task-3"]
3763 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
3764 .to_vec()
3765 );
3766 assert_eq!(client.overlay_rebuild_count.get(), 1);
3767 assert_eq!(client.outcome_prune_count.get(), 1);
3768 }
3769
3770 #[test]
3771 fn secondary_unique_collision_preserves_existing_synced_row() {
3772 let client = SyncClient::new(
3773 "unique-upsert-test".to_owned(),
3774 &json!({
3775 "version": 1,
3776 "tables": [{
3777 "name": "tasks",
3778 "primaryKey": "id",
3779 "columns": [
3780 { "name": "id", "type": "string", "nullable": false },
3781 { "name": "project_id", "type": "string", "nullable": false },
3782 { "name": "title", "type": "string", "nullable": false }
3783 ],
3784 "scopes": [{ "pattern": "project:{project_id}" }],
3785 "indexes": [{
3786 "name": "tasks_by_project_title",
3787 "columns": ["project_id", "title"],
3788 "unique": true
3789 }]
3790 }]
3791 }),
3792 ClientLimits::default(),
3793 )
3794 .expect("test client");
3795 let table = client.schema.table("tasks").expect("tasks table");
3796 let sql = client.insert_row_sql(&base_table("tasks"), table);
3797
3798 client
3799 .conn
3800 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
3801 .expect("insert first row");
3802 client
3803 .conn
3804 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
3805 .expect("update same primary key");
3806 client
3807 .conn
3808 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
3809 .expect("insert second row");
3810 assert!(client
3811 .conn
3812 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
3813 .is_err());
3814
3815 let rows = client
3816 .conn
3817 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
3818 .expect("prepare rows")
3819 .query_map([], |row| {
3820 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3821 })
3822 .expect("query rows")
3823 .collect::<Result<Vec<_>, _>>()
3824 .expect("collect rows");
3825 assert_eq!(
3826 rows,
3827 vec![
3828 ("t1".to_owned(), "updated".to_owned()),
3829 ("t2".to_owned(), "original".to_owned())
3830 ]
3831 );
3832 }
3833
3834 #[test]
3835 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
3836 let path = std::env::temp_dir().join(format!(
3837 "syncular-startup-intent-{}.db",
3838 uuid::Uuid::new_v4()
3839 ));
3840 let schema = json!({
3841 "version": 1,
3842 "tables": [{
3843 "name": "tasks",
3844 "primaryKey": "id",
3845 "columns": [
3846 { "name": "id", "type": "string", "nullable": false },
3847 { "name": "project_id", "type": "string", "nullable": false }
3848 ],
3849 "scopes": [{ "pattern": "project:{project_id}" }]
3850 }]
3851 });
3852
3853 {
3854 let mut first = SyncClient::open_path_with_identity(
3855 None,
3856 &schema,
3857 ClientLimits::default(),
3858 path.to_str().expect("UTF-8 temp path"),
3859 )
3860 .expect("first open");
3861 first
3862 .set_window(
3863 &WindowBase {
3864 table: "tasks".to_owned(),
3865 variable: "project_id".to_owned(),
3866 fixed_scopes: Vec::new(),
3867 params: None,
3868 },
3869 &["persisted".to_owned()],
3870 )
3871 .expect("persist window");
3872 }
3873
3874 let mut reopened = SyncClient::open_path_with_identity(
3875 None,
3876 &schema,
3877 ClientLimits::default(),
3878 path.to_str().expect("UTF-8 temp path"),
3879 )
3880 .expect("reopen");
3881 assert!(reopened.sync_needed());
3882 assert!(matches!(
3883 reopened.drain_sync_intents().as_slice(),
3884 [SyncIntent::Interactive]
3885 ));
3886 drop(reopened);
3887 std::fs::remove_file(path).expect("remove temp database");
3888 }
3889
3890 #[test]
3891 fn reopening_preserves_immutable_subscription_identity_and_progress() {
3892 let path = std::env::temp_dir().join(format!(
3893 "syncular-subscription-identity-{}.db",
3894 uuid::Uuid::new_v4()
3895 ));
3896 let schema = json!({
3897 "version": 1,
3898 "tables": [
3899 {
3900 "name": "tasks",
3901 "primaryKey": "id",
3902 "columns": [
3903 { "name": "id", "type": "string", "nullable": false },
3904 { "name": "project_id", "type": "string", "nullable": false }
3905 ],
3906 "scopes": [{ "pattern": "project:{project_id}" }]
3907 },
3908 {
3909 "name": "docs",
3910 "primaryKey": "id",
3911 "columns": [
3912 { "name": "id", "type": "string", "nullable": false },
3913 { "name": "org_id", "type": "string", "nullable": false },
3914 { "name": "project_id", "type": "string", "nullable": false }
3915 ],
3916 "scopes": [
3917 { "pattern": "org:{org_id}" },
3918 { "pattern": "project:{projectId}", "column": "project_id" }
3919 ]
3920 }
3921 ]
3922 });
3923
3924 {
3925 let mut first = SyncClient::open_path_with_identity(
3926 None,
3927 &schema,
3928 ClientLimits::default(),
3929 path.to_str().expect("UTF-8 temp path"),
3930 )
3931 .expect("first open");
3932 first
3933 .subscribe(
3934 "stable-subscription".to_owned(),
3935 "tasks".to_owned(),
3936 vec![(
3937 "project_id".to_owned(),
3938 vec!["p2".to_owned(), "p1".to_owned()],
3939 )],
3940 Some(r#"{"view":"v1"}"#.to_owned()),
3941 )
3942 .expect("persist subscription");
3943 let persisted = {
3944 let subscription = first
3945 .subs
3946 .iter_mut()
3947 .find(|subscription| subscription.id == "stable-subscription")
3948 .expect("subscription");
3949 subscription.cursor = 41;
3950 subscription.bootstrap_state = Some("resume-token".to_owned());
3951 subscription.effective = Some(vec![(
3952 "project_id".to_owned(),
3953 vec!["p1".to_owned(), "p2".to_owned()],
3954 )]);
3955 subscription.synced_once = true;
3956 subscription.clone()
3957 };
3958 first.persist_sub(&persisted).unwrap();
3959 }
3960
3961 let mut reopened = SyncClient::open_path_with_identity(
3962 None,
3963 &schema,
3964 ClientLimits::default(),
3965 path.to_str().expect("UTF-8 temp path"),
3966 )
3967 .expect("reopen");
3968 let progress = reopened
3969 .subscription_state("stable-subscription")
3970 .expect("persisted state");
3971 assert_eq!(progress.cursor, 41);
3972 assert!(progress.has_resume_token);
3973
3974 reopened
3975 .subscribe(
3976 "stable-subscription".to_owned(),
3977 "tasks".to_owned(),
3978 vec![(
3979 "project_id".to_owned(),
3980 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3981 )],
3982 Some(r#"{"view":"v1"}"#.to_owned()),
3983 )
3984 .expect("canonical intent is idempotent");
3985 assert_eq!(
3986 reopened
3987 .subscription_state("stable-subscription")
3988 .expect("unchanged state")
3989 .cursor,
3990 progress.cursor
3991 );
3992
3993 for (table, scopes, params) in [
3994 (
3995 "tasks",
3996 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3997 Some(r#"{"view":"v1"}"#.to_owned()),
3998 ),
3999 (
4000 "tasks",
4001 vec![(
4002 "project_id".to_owned(),
4003 vec!["p2".to_owned(), "p1".to_owned()],
4004 )],
4005 Some(r#"{"view":"v2"}"#.to_owned()),
4006 ),
4007 (
4008 "docs",
4009 vec![
4010 ("org_id".to_owned(), vec!["o1".to_owned()]),
4011 ("projectId".to_owned(), vec!["p1".to_owned()]),
4012 ],
4013 Some(r#"{"view":"v1"}"#.to_owned()),
4014 ),
4015 ] {
4016 let error = reopened
4017 .subscribe(
4018 "stable-subscription".to_owned(),
4019 table.to_owned(),
4020 scopes,
4021 params,
4022 )
4023 .expect_err("identity rebind must fail");
4024 assert!(error.starts_with("client.subscription_intent_mismatch:"));
4025 assert_eq!(
4026 reopened
4027 .subscription_state("stable-subscription")
4028 .expect("unchanged state")
4029 .cursor,
4030 progress.cursor
4031 );
4032 }
4033
4034 drop(reopened);
4035 std::fs::remove_file(path).expect("remove temp database");
4036 }
4037
4038 #[test]
4039 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
4040 let path = std::env::temp_dir().join(format!(
4041 "syncular-satisfied-schema-floor-{}.db",
4042 uuid::Uuid::new_v4()
4043 ));
4044 let schema = json!({
4045 "version": 23,
4046 "tables": [{
4047 "name": "tasks",
4048 "primaryKey": "id",
4049 "columns": [
4050 { "name": "id", "type": "string", "nullable": false },
4051 { "name": "project_id", "type": "string", "nullable": false }
4052 ],
4053 "scopes": [{ "pattern": "project:{project_id}" }]
4054 }]
4055 });
4056
4057 {
4058 let mut first = SyncClient::open_path_with_identity(
4059 None,
4060 &schema,
4061 ClientLimits::default(),
4062 path.to_str().expect("UTF-8 temp path"),
4063 )
4064 .expect("first open");
4065 first
4066 .subscribe(
4067 "tasks".to_owned(),
4068 "tasks".to_owned(),
4069 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
4070 None,
4071 )
4072 .expect("persist subscription");
4073 first.set_schema_floor(Some(SchemaFloor {
4074 required_schema_version: Some(22),
4075 latest_schema_version: Some(22),
4076 }));
4077 }
4078
4079 let mut reopened = SyncClient::open_path_with_identity(
4080 None,
4081 &schema,
4082 ClientLimits::default(),
4083 path.to_str().expect("UTF-8 temp path"),
4084 )
4085 .expect("reopen");
4086 assert!(reopened.schema_floor().is_none());
4087 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
4088 assert!(reopened.sync_needed());
4089 assert!(matches!(
4090 reopened.drain_sync_intents().as_slice(),
4091 [SyncIntent::Interactive]
4092 ));
4093 drop(reopened);
4094 std::fs::remove_file(path).expect("remove temp database");
4095 }
4096
4097 #[test]
4098 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
4099 let path = std::env::temp_dir().join(format!(
4100 "syncular-unsatisfied-schema-floor-{}.db",
4101 uuid::Uuid::new_v4()
4102 ));
4103 let schema = json!({
4104 "version": 1,
4105 "tables": [{
4106 "name": "tasks",
4107 "primaryKey": "id",
4108 "columns": [
4109 { "name": "id", "type": "string", "nullable": false },
4110 { "name": "project_id", "type": "string", "nullable": false }
4111 ],
4112 "scopes": [{ "pattern": "project:{project_id}" }]
4113 }]
4114 });
4115
4116 {
4117 let mut first = SyncClient::open_path_with_identity(
4118 None,
4119 &schema,
4120 ClientLimits::default(),
4121 path.to_str().expect("UTF-8 temp path"),
4122 )
4123 .expect("first open");
4124 first
4125 .subscribe(
4126 "tasks".to_owned(),
4127 "tasks".to_owned(),
4128 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
4129 None,
4130 )
4131 .expect("persist subscription");
4132 first.set_schema_floor(Some(SchemaFloor {
4133 required_schema_version: Some(2),
4134 latest_schema_version: Some(2),
4135 }));
4136 }
4137
4138 let reopened = SyncClient::open_path_with_identity(
4139 None,
4140 &schema,
4141 ClientLimits::default(),
4142 path.to_str().expect("UTF-8 temp path"),
4143 )
4144 .expect("reopen");
4145 assert_eq!(
4146 reopened.schema_floor(),
4147 Some(&SchemaFloor {
4148 required_schema_version: Some(2),
4149 latest_schema_version: Some(2),
4150 })
4151 );
4152 assert!(!reopened.sync_needed());
4153 drop(reopened);
4154 std::fs::remove_file(path).expect("remove temp database");
4155 }
4156
4157 #[test]
4158 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
4159 let path = std::env::temp_dir().join(format!(
4160 "syncular-indexed-column-bump-{}.db",
4161 uuid::Uuid::new_v4()
4162 ));
4163 let old_schema = json!({
4164 "version": 1,
4165 "tables": [
4166 {
4167 "name": "tasks",
4168 "primaryKey": "id",
4169 "columns": [
4170 { "name": "id", "type": "string", "nullable": false },
4171 { "name": "project_id", "type": "string", "nullable": false }
4172 ],
4173 "scopes": [{ "pattern": "project:{project_id}" }]
4174 },
4175 {
4176 "name": "legacy",
4177 "primaryKey": "id",
4178 "columns": [
4179 { "name": "id", "type": "string", "nullable": false },
4180 { "name": "project_id", "type": "string", "nullable": false }
4181 ],
4182 "scopes": [{ "pattern": "project:{project_id}" }]
4183 }
4184 ]
4185 });
4186 {
4187 let mut first = SyncClient::open_path_with_identity(
4188 None,
4189 &old_schema,
4190 ClientLimits::default(),
4191 path.to_str().expect("UTF-8 temp path"),
4192 )
4193 .expect("open old schema");
4194 first
4195 .subscribe(
4196 "legacy-sub".to_owned(),
4197 "legacy".to_owned(),
4198 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
4199 None,
4200 )
4201 .expect("persist legacy subscription");
4202 }
4203
4204 let new_schema = json!({
4205 "version": 2,
4206 "tables": [{
4207 "name": "tasks",
4208 "primaryKey": "id",
4209 "columns": [
4210 { "name": "id", "type": "string", "nullable": false },
4211 { "name": "project_id", "type": "string", "nullable": false },
4212 { "name": "facility_membership_id", "type": "string", "nullable": true }
4213 ],
4214 "scopes": [{ "pattern": "project:{project_id}" }],
4215 "indexes": [{
4216 "name": "tasks_by_membership",
4217 "columns": ["project_id", "facility_membership_id"],
4218 "unique": false
4219 }]
4220 }]
4221 });
4222 let upgraded = SyncClient::open_path_with_identity(
4223 None,
4224 &new_schema,
4225 ClientLimits::default(),
4226 path.to_str().expect("UTF-8 temp path"),
4227 )
4228 .expect("open upgraded schema");
4229 let columns = upgraded
4230 .conn
4231 .prepare("PRAGMA table_info(tasks)")
4232 .expect("prepare columns")
4233 .query_map([], |row| row.get::<_, String>(1))
4234 .expect("query columns")
4235 .collect::<Result<Vec<_>, _>>()
4236 .expect("collect columns");
4237 assert!(columns.contains(&"facility_membership_id".to_owned()));
4238 let index_count: i64 = upgraded
4239 .conn
4240 .query_row(
4241 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
4242 [],
4243 |row| row.get(0),
4244 )
4245 .expect("query index");
4246 assert_eq!(index_count, 1);
4247 assert!(upgraded.subscription_state("legacy-sub").is_none());
4248 drop(upgraded);
4249 std::fs::remove_file(path).expect("remove temp database");
4250 }
4251
4252 #[test]
4253 fn migrates_pre_envelope_outcome_journal_additively() {
4254 let path = std::env::temp_dir().join(format!(
4255 "syncular-outcome-migration-{}.db",
4256 uuid::Uuid::new_v4()
4257 ));
4258 let conn = Connection::open(&path).expect("open legacy database");
4259 conn.execute_batch(
4260 "CREATE TABLE _syncular_commit_outcomes (
4261 seq INTEGER PRIMARY KEY AUTOINCREMENT,
4262 client_commit_id TEXT NOT NULL UNIQUE,
4263 status TEXT NOT NULL,
4264 recorded_at_ms INTEGER NOT NULL,
4265 results_json TEXT NOT NULL,
4266 resolution TEXT NOT NULL DEFAULT 'active',
4267 resolved_at_ms INTEGER,
4268 replacement_client_commit_id TEXT);",
4269 )
4270 .expect("create legacy outcome journal");
4271 drop(conn);
4272 let schema = json!({
4273 "version": 1,
4274 "tables": [{
4275 "name": "tasks",
4276 "primaryKey": "id",
4277 "columns": [
4278 { "name": "id", "type": "string", "nullable": false },
4279 { "name": "project_id", "type": "string", "nullable": false }
4280 ],
4281 "scopes": [{ "pattern": "project:{project_id}" }]
4282 }]
4283 });
4284 let client = SyncClient::open_path(
4285 "migration-native".to_owned(),
4286 &schema,
4287 ClientLimits::default(),
4288 path.to_str().expect("UTF-8 temp path"),
4289 )
4290 .expect("migrate database");
4291 let has_operations = client
4292 .conn
4293 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
4294 .expect("prepare table info")
4295 .query_map([], |row| row.get::<_, String>(1))
4296 .expect("query table info")
4297 .filter_map(Result::ok)
4298 .any(|column| column == "operations_json");
4299 assert!(has_operations);
4300 drop(client);
4301 std::fs::remove_file(path).expect("remove temp database");
4302 }
4303
4304 #[test]
4305 fn retained_rejection_survives_file_reopen_and_security_purge() {
4306 let path =
4307 std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
4308 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
4309 { "name": "id", "type": "string", "nullable": false },
4310 { "name": "project_id", "type": "string", "nullable": false },
4311 { "name": "title", "type": "string", "nullable": false }
4312 ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
4313 {
4314 let mut first = SyncClient::open_path(
4315 "retained-native".to_owned(),
4316 &schema,
4317 ClientLimits::default(),
4318 path.to_str().unwrap(),
4319 )
4320 .unwrap();
4321 first
4322 .write_base_row(
4323 "tasks",
4324 &vec![
4325 Some(ColumnValue::String("t1".into())),
4326 Some(ColumnValue::String("p1".into())),
4327 Some(ColumnValue::String("server".into())),
4328 ],
4329 1,
4330 )
4331 .unwrap();
4332 first.rebuild_overlay();
4333 let operation = OutboxOp {
4334 table: "tasks".into(),
4335 row_id: "t1".into(),
4336 upsert: true,
4337 base_version: Some(1),
4338 values: Some(Map::from_iter([
4339 ("id".into(), json!("t1")),
4340 ("title".into(), json!("mine")),
4341 ])),
4342 };
4343 first
4344 .retain_failed_commit("retained", std::slice::from_ref(&operation))
4345 .unwrap();
4346 first
4347 .persist_commit_outcome(
4348 "retained",
4349 CommitOutcomeStatus::Rejected,
4350 &[],
4351 Some(&[operation]),
4352 )
4353 .unwrap();
4354 first.rebuild_overlay();
4355 }
4356 {
4357 let mut reopened = SyncClient::open_path(
4358 "retained-native".to_owned(),
4359 &schema,
4360 ClientLimits::default(),
4361 path.to_str().unwrap(),
4362 )
4363 .unwrap();
4364 let row = reopened
4365 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
4366 .unwrap();
4367 assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
4368 assert_eq!(
4369 reopened
4370 .commit_outcome("retained")
4371 .unwrap()
4372 .unwrap()
4373 .retained_rows
4374 .unwrap()[0]
4375 .server_row
4376 .as_ref()
4377 .unwrap()["title"],
4378 json!("server")
4379 );
4380 reopened
4381 .conn
4382 .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
4383 .unwrap();
4384 reopened.rebuild_overlay();
4385 assert!(reopened
4386 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
4387 .is_none());
4388 let retained = reopened.commit_outcome("retained").unwrap().unwrap();
4389 assert!(retained.retained_rows.is_some());
4390 assert_eq!(
4391 retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
4392 json!("mine")
4393 );
4394 reopened
4395 .purge_local_data(&LocalDataPurgeInput {
4396 purge_id: "protected-purge".into(),
4397 targets: vec![LocalDataPurgeTarget {
4398 table: "tasks".into(),
4399 selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
4400 }],
4401 })
4402 .unwrap();
4403 assert!(reopened
4404 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
4405 .is_none());
4406 assert!(reopened
4407 .commit_outcome("retained")
4408 .unwrap()
4409 .unwrap()
4410 .retained_rows
4411 .is_none());
4412 }
4413 std::fs::remove_file(path).unwrap();
4414 }
4415
4416 #[test]
4417 fn durable_conflict_outcome_and_resolution_survive_reopen() {
4418 let path = std::env::temp_dir().join(format!(
4419 "syncular-durable-outcome-{}.db",
4420 uuid::Uuid::new_v4()
4421 ));
4422 let schema = json!({
4423 "version": 1,
4424 "tables": [{
4425 "name": "tasks",
4426 "primaryKey": "id",
4427 "columns": [
4428 { "name": "id", "type": "string", "nullable": false },
4429 { "name": "project_id", "type": "string", "nullable": false }
4430 ],
4431 "scopes": [{ "pattern": "project:{project_id}" }]
4432 }]
4433 });
4434 let conflict = ConflictRecord {
4435 client_commit_id: "losing-commit".to_owned(),
4436 op_index: 0,
4437 table: "tasks".to_owned(),
4438 row_id: "t1".to_owned(),
4439 code: "sync.version_conflict".to_owned(),
4440 message: "stale base version".to_owned(),
4441 server_version: 2,
4442 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
4443 conflict_columns: vec!["project_id".to_owned()],
4444 operation: Some(CommitOperation {
4445 table: "tasks".to_owned(),
4446 row_id: "t1".to_owned(),
4447 op: "upsert".to_owned(),
4448 base_version: Some(1),
4449 values: None,
4450 }),
4451 };
4452 let failed_operations = vec![
4453 OutboxOp {
4454 upsert: true,
4455 table: "tasks".to_owned(),
4456 row_id: "t1".to_owned(),
4457 base_version: Some(1),
4458 values: None,
4459 },
4460 OutboxOp {
4461 upsert: true,
4462 table: "tasks".to_owned(),
4463 row_id: "status-event-1".to_owned(),
4464 base_version: Some(0),
4465 values: None,
4466 },
4467 ];
4468
4469 {
4470 let mut first = SyncClient::open_path(
4471 "durable-native".to_owned(),
4472 &schema,
4473 ClientLimits::default(),
4474 path.to_str().expect("UTF-8 temp path"),
4475 )
4476 .expect("first open");
4477 first
4478 .begin_observation("test_outcome")
4479 .expect("begin outcome");
4480 first
4481 .persist_commit_outcome(
4482 "losing-commit",
4483 CommitOutcomeStatus::Conflict,
4484 &[CommitOperationOutcome::Conflict {
4485 conflict: conflict.clone(),
4486 }],
4487 Some(&failed_operations),
4488 )
4489 .expect("persist outcome");
4490 first.conflicts.push(conflict);
4491 first
4492 .finish_observation(
4493 "test_outcome",
4494 ChangeAccumulator {
4495 conflicts: true,
4496 outcomes: true,
4497 ..ChangeAccumulator::default()
4498 },
4499 )
4500 .expect("commit outcome");
4501 }
4502
4503 {
4504 let mut reopened = SyncClient::open_path(
4505 "durable-native".to_owned(),
4506 &schema,
4507 ClientLimits::default(),
4508 path.to_str().expect("UTF-8 temp path"),
4509 )
4510 .expect("reopen");
4511 assert_eq!(reopened.conflicts().len(), 1);
4512 assert_eq!(
4513 reopened.conflicts()[0].conflict_columns,
4514 vec!["project_id".to_owned()],
4515 "conflictColumns survives the journal round trip"
4516 );
4517 let outcome = reopened
4518 .commit_outcome("losing-commit")
4519 .expect("read outcome")
4520 .expect("outcome");
4521 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
4522 let operations = outcome.operations.expect("aggregate envelope");
4523 assert_eq!(operations.len(), 2);
4524 assert_eq!(operations[1].row_id, "status-event-1");
4525 let resolved = reopened
4526 .resolve_commit_outcome(ResolveCommitOutcomeInput {
4527 client_commit_id: "losing-commit".to_owned(),
4528 resolution: CommitOutcomeResolution::ResolvedKeepServer,
4529 replacement_client_commit_id: None,
4530 })
4531 .expect("resolve");
4532 assert_eq!(
4533 resolved.resolution,
4534 CommitOutcomeResolution::ResolvedKeepServer
4535 );
4536 assert!(reopened.conflicts().is_empty());
4537 }
4538
4539 let reopened = SyncClient::open_path(
4540 "durable-native".to_owned(),
4541 &schema,
4542 ClientLimits::default(),
4543 path.to_str().expect("UTF-8 temp path"),
4544 )
4545 .expect("second reopen");
4546 assert!(reopened.conflicts().is_empty());
4547 assert_eq!(
4548 reopened
4549 .commit_outcome("losing-commit")
4550 .expect("read outcome")
4551 .expect("outcome")
4552 .resolution,
4553 CommitOutcomeResolution::ResolvedKeepServer
4554 );
4555 drop(reopened);
4556 std::fs::remove_file(path).expect("remove temp database");
4557 }
4558
4559 #[test]
4560 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
4561 let path =
4562 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
4563 let schema = json!({
4564 "version": 1,
4565 "tables": [{
4566 "name": "tasks",
4567 "primaryKey": "id",
4568 "columns": [
4569 { "name": "id", "type": "string", "nullable": false },
4570 { "name": "project_id", "type": "string", "nullable": false }
4571 ],
4572 "scopes": [{ "pattern": "project:{project_id}" }]
4573 }]
4574 });
4575 let mut client = SyncClient::open_path_with_identity(
4576 Some("sidecar-client".to_owned()),
4577 &schema,
4578 ClientLimits::default(),
4579 path.to_str().expect("UTF-8 temp path"),
4580 )
4581 .expect("open owner");
4582 let base = WindowBase {
4583 table: "tasks".to_owned(),
4584 variable: "project_id".to_owned(),
4585 fixed_scopes: Vec::new(),
4586 params: None,
4587 };
4588 client
4589 .set_window(&base, &["one".to_owned()])
4590 .expect("set window");
4591 client
4592 .mutate(vec![Mutation::Upsert {
4593 table: "tasks".to_owned(),
4594 values: Map::from_iter([
4595 ("id".to_owned(), Value::from("t1")),
4596 ("project_id".to_owned(), Value::from("one")),
4597 ]),
4598 base_version: None,
4599 }])
4600 .expect("local mutate");
4601
4602 let coverage = [WindowCoverage {
4603 base,
4604 units: vec!["one".to_owned(), "missing".to_owned()],
4605 }];
4606 let owner = client
4607 .query_snapshot(
4608 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4609 &[],
4610 &coverage,
4611 None,
4612 )
4613 .expect("owner snapshot");
4614 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
4615 let sidecar = reader
4616 .query_snapshot(
4617 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4618 &[],
4619 &coverage,
4620 )
4621 .expect("sidecar snapshot");
4622
4623 assert_eq!(sidecar.revision, owner.revision);
4624 assert_eq!(sidecar.rows, owner.rows);
4625 assert_eq!(
4626 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
4627 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
4628 );
4629 assert_eq!(sidecar.revision, "2");
4630 assert_eq!(sidecar.rows[0]["id"], "t1");
4631 assert_eq!(sidecar.rows[0]["server_version"], -1);
4632 assert!(!sidecar.coverage.complete);
4633 assert_eq!(sidecar.coverage.pending.len(), 1);
4634 assert_eq!(sidecar.coverage.missing.len(), 1);
4635
4636 drop(reader);
4637 drop(client);
4638 std::fs::remove_file(path).expect("remove temp database");
4639 }
4640
4641 #[test]
4642 fn owned_query_failures_classify_order_bound_and_clear() {
4643 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4644 rusqlite::ffi::Error::new(266),
4645 Some("private SQLite prose".to_owned()),
4646 ));
4647 assert_eq!(io.code, Some("client.storage_io"));
4648 assert_eq!(io.sqlite_code, Some(266));
4649 assert_eq!(io.message, "local SQLite storage I/O failed");
4650 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4651 rusqlite::ffi::Error::new(26),
4652 None,
4653 ));
4654 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
4655
4656 let mut client = client();
4657 let generic = QueryReadFailure::from("private query prose".to_owned());
4658 client.record_query_read(
4659 QueryOwner {
4660 id: "first",
4661 tables: &["tasks", "tasks"],
4662 },
4663 Some(&generic),
4664 );
4665 client.record_query_read(
4666 QueryOwner {
4667 id: "second",
4668 tables: &["tasks"],
4669 },
4670 Some(&generic),
4671 );
4672 client.record_query_read(
4673 QueryOwner {
4674 id: "first",
4675 tables: &["docs"],
4676 },
4677 Some(&io),
4678 );
4679 let failures = &client
4680 .diagnostics_snapshot(&Default::default())
4681 .expect("diagnostics")
4682 .query_failures;
4683 assert_eq!(
4684 failures
4685 .iter()
4686 .map(|entry| entry.id.as_str())
4687 .collect::<Vec<_>>(),
4688 vec!["second", "first"]
4689 );
4690 assert_eq!(failures[1].tables, vec!["docs"]);
4691 assert_eq!(failures[1].code, "client.storage_io");
4692
4693 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
4694 client.record_query_read(
4695 QueryOwner {
4696 id: &format!("query-{index}"),
4697 tables: &["tasks"],
4698 },
4699 Some(&generic),
4700 );
4701 }
4702 let failures = &client
4703 .diagnostics_snapshot(&Default::default())
4704 .expect("bounded diagnostics")
4705 .query_failures;
4706 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
4707 assert_eq!(failures.last().expect("last failure").id, "query-256");
4708
4709 client
4710 .query_snapshot(
4711 "SELECT id FROM tasks",
4712 &[],
4713 &[],
4714 Some(QueryOwner {
4715 id: "query-256",
4716 tables: &["tasks"],
4717 }),
4718 )
4719 .expect("successful owned read");
4720 assert!(client
4721 .diagnostics_snapshot(&Default::default())
4722 .expect("cleared diagnostics")
4723 .query_failures
4724 .iter()
4725 .all(|entry| entry.id != "query-256"));
4726 }
4727
4728 #[test]
4729 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
4730 let mut client = client();
4731 let image = Connection::open_in_memory().unwrap();
4732 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
4733 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4734 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
4735 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4736 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
4737 let table = client.schema.table("tasks").unwrap().clone();
4738 let scopes = vec![("project_id".into(), vec!["p1".into()])];
4739 assert!(client
4740 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
4741 .is_err());
4742 assert!(client.conn.is_autocommit());
4743 assert_eq!(
4744 client
4745 .query("SELECT count(*) AS n FROM tasks", &[])
4746 .unwrap()[0]["n"],
4747 1024
4748 );
4749 assert_eq!(client.drain_change_batches().len(), 1);
4750 image
4751 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
4752 .unwrap();
4753 assert!(matches!(
4754 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
4755 Ok(2050)
4756 ));
4757 assert_eq!(
4758 client
4759 .query("SELECT count(*) AS n FROM tasks", &[])
4760 .unwrap()[0]["n"],
4761 2050
4762 );
4763 assert_eq!(client.drain_change_batches().len(), 3);
4764 }
4765
4766 #[test]
4767 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
4768 let mut client = client();
4769 let base = WindowBase {
4770 table: "tasks".into(),
4771 variable: "project_id".into(),
4772 fixed_scopes: Vec::new(),
4773 params: None,
4774 };
4775 client
4776 .set_window(&base, &["p1".into(), "p2".into()])
4777 .unwrap();
4778 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4779 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4780 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
4781 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4782 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4783 client.drain_change_batches();
4784 assert!(client.set_window(&base, &["p2".into()]).is_err());
4785 assert!(client.conn.is_autocommit());
4786 assert_eq!(
4787 client
4788 .query("SELECT count(*) AS n FROM tasks", &[])
4789 .unwrap()[0]["n"],
4790 1027
4791 );
4792 assert_eq!(client.window_state(&base).units, vec!["p2"]);
4793 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
4794 assert_eq!(client.drain_change_batches().len(), 1);
4795 client
4796 .conn
4797 .execute_batch("DROP TRIGGER interrupt_evict")
4798 .unwrap();
4799 client.drain_pending_evictions().unwrap();
4800 assert!(client.load_pending_evictions().unwrap().is_empty());
4801 assert_eq!(
4802 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
4803 "held"
4804 );
4805 assert_eq!(client.drain_change_batches().len(), 2);
4806 }
4807
4808 #[test]
4809 fn eviction_marker_failures_roll_back_window_changes() {
4810 let mut client = client();
4811 let base = WindowBase {
4812 table: "tasks".into(),
4813 variable: "project_id".into(),
4814 fixed_scopes: Vec::new(),
4815 params: None,
4816 };
4817 client.set_window(&base, &["p1".into()]).unwrap();
4818 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4819 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4820 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4821 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4822 let revision = client.local_revision();
4823 assert!(client.set_window(&base, &[]).is_err());
4824 assert!(client.conn.is_autocommit());
4825 assert_eq!(client.local_revision(), revision);
4826 assert_eq!(
4827 client
4828 .query("SELECT count(*) AS n FROM tasks", &[])
4829 .unwrap()[0]["n"],
4830 2050
4831 );
4832 assert_eq!(client.window_state(&base).units, vec!["p1"]);
4833 client.conn.execute_batch("DROP TRIGGER fail_marker;
4834 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4835 assert!(client.set_window(&base, &[]).is_err());
4836 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
4837 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4838 assert!(client.drain_pending_evictions().is_err());
4839 assert!(client.conn.is_autocommit());
4840 assert_eq!(
4841 client
4842 .query("SELECT count(*) AS n FROM tasks", &[])
4843 .unwrap()[0]["n"],
4844 2
4845 );
4846 assert!(client.set_window(&base, &["p1".into()]).is_err());
4847 assert!(client.conn.is_autocommit());
4848 assert!(client.window_state(&base).units.is_empty());
4849 assert!(client.subs.is_empty());
4850 assert_eq!(
4851 client
4852 .query(
4853 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
4854 &[]
4855 )
4856 .unwrap()[0]["n"],
4857 1
4858 );
4859 client
4860 .conn
4861 .execute_batch("DROP TRIGGER fail_marker")
4862 .unwrap();
4863 client.set_window(&base, &["p1".into()]).unwrap();
4864 assert_eq!(client.window_state(&base).units, vec!["p1"]);
4865 }
4866
4867 #[test]
4868 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
4869 let mut client = client();
4870 client
4871 .conn
4872 .execute(
4873 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
4874 [],
4875 )
4876 .unwrap();
4877 assert_eq!(
4878 client.drain_pending_evictions().unwrap_err(),
4879 "sync.local_corrupt: invalid pending eviction scopes"
4880 );
4881 assert!(client.conn.is_autocommit());
4882 assert!(client.enqueue_startup_sync_if_needed().is_err());
4883 }
4884
4885 #[test]
4886 fn image_chunks_reject_null_primary_keys() {
4887 let mut client = client();
4888 let image = Connection::open_in_memory().unwrap();
4889 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4890 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
4891 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
4892 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4893 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
4894 let table = client.schema.table("tasks").unwrap().clone();
4895 assert!(client
4896 .apply_sqlite_image(
4897 &image,
4898 &table,
4899 true,
4900 &[("project_id".into(), vec!["p1".into()])],
4901 1025,
4902 7,
4903 "digest"
4904 )
4905 .is_err());
4906 assert!(client.conn.is_autocommit());
4907 assert_eq!(
4908 client
4909 .query("SELECT count(*) AS n FROM tasks", &[])
4910 .unwrap()[0]["n"],
4911 0
4912 );
4913 }
4914
4915 #[test]
4916 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
4917 for images in [false, true] {
4918 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4919 "name":name,"primaryKey":"id","columns":[
4920 {"name":"id","type":"string","nullable":false},
4921 {"name":"project_id","type":"string","nullable":false},
4922 {"name":"title","type":"string","nullable":false}],
4923 "scopes":[{"pattern":"project:{project_id}"}],
4924 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
4925 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4926 })))});
4927 let mut actual =
4928 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
4929 let mut reference =
4930 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
4931 let scopes = vec![("project_id".into(), vec!["p1".into()])];
4932 for client in [&mut actual, &mut reference] {
4933 client
4934 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
4935 .unwrap();
4936 client.subs[0].effective = Some(scopes.clone());
4937 for (table, id, title) in [
4938 ("tasks", "a", "original"),
4939 ("tasks", "b", "occupied"),
4940 ("tasks", "c", "deleted"),
4941 ("notes", "n", "untouched"),
4942 ] {
4943 client
4944 .write_base_row(
4945 table,
4946 &vec![
4947 Some(ColumnValue::String(id.into())),
4948 Some(ColumnValue::String("p1".into())),
4949 Some(ColumnValue::String(title.into())),
4950 ],
4951 1,
4952 )
4953 .unwrap();
4954 }
4955 client.rebuild_overlay_if_dirty();
4956 client
4957 .mutate(vec![
4958 Mutation::Upsert {
4959 table: "tasks".into(),
4960 values: Map::from_iter([
4961 ("id".into(), Value::from("a")),
4962 ("project_id".into(), Value::from("p1")),
4963 ("title".into(), Value::from("available")),
4964 ]),
4965 base_version: None,
4966 },
4967 Mutation::Delete {
4968 table: "tasks".into(),
4969 row_id: "c".into(),
4970 base_version: None,
4971 },
4972 Mutation::Upsert {
4973 table: "tasks".into(),
4974 values: Map::from_iter([
4975 ("id".into(), Value::from("local")),
4976 ("project_id".into(), Value::from("p1")),
4977 ("title".into(), Value::from("local value")),
4978 ]),
4979 base_version: None,
4980 },
4981 Mutation::Upsert {
4982 table: "notes".into(),
4983 values: Map::from_iter([
4984 ("id".into(), Value::from("n")),
4985 ("project_id".into(), Value::from("p1")),
4986 ("title".into(), Value::from("pending note")),
4987 ]),
4988 base_version: None,
4989 },
4990 ])
4991 .unwrap();
4992 }
4993 let pending = actual.pending_commit_ids();
4994 actual.overlay_rebuild_count.set(0);
4995 for (id, title) in [
4996 ("b", "available"),
4997 ("b", "occupied"),
4998 ("a", "server original"),
4999 ("d", "local value"),
5000 ("d", "released"),
5001 ("c", "redelivered"),
5002 ] {
5003 let table = actual.schema.table("tasks").unwrap().clone();
5004 let values = vec![
5005 Some(ColumnValue::String(id.into())),
5006 Some(ColumnValue::String("p1".into())),
5007 Some(ColumnValue::String(title.into())),
5008 ];
5009 if images {
5010 let image = Connection::open_in_memory().unwrap();
5011 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
5012 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
5013 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
5014 image
5015 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
5016 .unwrap();
5017 assert!(matches!(
5018 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
5019 Ok(1)
5020 ));
5021 } else {
5022 let segment = RowsSegment {
5023 table: "tasks".into(),
5024 schema_version: 1,
5025 columns: table.wire_columns.clone(),
5026 blocks: vec![vec![ssp2::segment::SegmentRow {
5027 server_version: 2,
5028 values: values.clone(),
5029 }]],
5030 };
5031 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
5032 }
5033 reference.write_base_row("tasks", &values, 2).unwrap();
5034 reference.rebuild_overlay();
5035 for table in ["tasks", "notes"] {
5036 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")] {
5037 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
5038 }
5039 }
5040 assert_eq!(actual.pending_commit_ids(), pending);
5041 }
5042 assert_eq!(
5043 actual.overlay_rebuild_count.get(),
5044 0,
5045 "each chunk must leave unrelated tables alone"
5046 );
5047 let before = actual
5048 .query("SELECT * FROM tasks ORDER BY id", &[])
5049 .unwrap();
5050 let fts_before = actual
5051 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
5052 .unwrap();
5053 let revision = actual.local_revision();
5054 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
5055 let table = actual.schema.table("tasks").unwrap().clone();
5056 let segment = RowsSegment {
5057 table: "tasks".into(),
5058 schema_version: 1,
5059 columns: table.wire_columns.clone(),
5060 blocks: vec![vec![ssp2::segment::SegmentRow {
5061 server_version: 3,
5062 values: vec![
5063 Some(ColumnValue::String("b".into())),
5064 Some(ColumnValue::String("p1".into())),
5065 Some(ColumnValue::String("fresh".into())),
5066 ],
5067 }]],
5068 };
5069 assert!(actual.apply_segment(0, &segment, false).is_err());
5070 assert!(actual.conn.is_autocommit());
5071 assert_eq!(
5072 actual
5073 .query("SELECT * FROM tasks ORDER BY id", &[])
5074 .unwrap(),
5075 before
5076 );
5077 assert_eq!(
5078 actual
5079 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
5080 .unwrap(),
5081 fts_before
5082 );
5083 assert_eq!(actual.local_revision(), revision);
5084 assert_eq!(actual.pending_commit_ids(), pending);
5085 }
5086 }
5087
5088 #[test]
5089 fn import_reconciliation_preserves_typed_pending_primary_keys() {
5090 for (kind, a, b) in [
5091 ("string", json!("a"), json!("b")),
5092 ("integer", json!(1), json!(2)),
5093 ("boolean", json!(true), json!(false)),
5094 ("json", json!("1"), json!("2")),
5095 ] {
5096 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}]}]});
5097 for images in [false, true] {
5098 let mut client =
5099 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
5100 let table = client.schema.table("tasks").unwrap().clone();
5101 client
5102 .subscribe("tasks".into(), "tasks".into(), vec![], None)
5103 .unwrap();
5104 for (id, title) in [(&a, "original"), (&b, "occupied")] {
5105 client
5106 .write_base_row(
5107 "tasks",
5108 &vec![
5109 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
5110 Some(ColumnValue::String(title.into())),
5111 ],
5112 1,
5113 )
5114 .unwrap();
5115 }
5116 client.rebuild_overlay_if_dirty();
5117 client
5118 .mutate(vec![Mutation::Upsert {
5119 table: "tasks".into(),
5120 values: Map::from_iter([
5121 ("id".into(), a.clone()),
5122 ("title".into(), json!("available")),
5123 ]),
5124 base_version: None,
5125 }])
5126 .unwrap();
5127 client.overlay_rebuild_count.set(0);
5128 for (title, expected) in [("available", "original"), ("occupied", "available")] {
5129 let values = vec![
5130 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
5131 Some(ColumnValue::String(title.into())),
5132 ];
5133 if images {
5134 let image = Connection::open_in_memory().unwrap();
5135 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
5136 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
5137 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
5138 image
5139 .execute(
5140 "INSERT INTO tasks VALUES (?1,?2,2)",
5141 rusqlite::params![RowParam::Cell(&values[0]), title],
5142 )
5143 .unwrap();
5144 assert!(
5145 matches!(
5146 client.apply_sqlite_image(
5147 &image,
5148 &table,
5149 false,
5150 &[],
5151 1,
5152 7,
5153 "digest"
5154 ),
5155 Ok(1)
5156 ),
5157 "{kind}, images={images}"
5158 );
5159 } else {
5160 let segment = RowsSegment {
5161 table: "tasks".into(),
5162 schema_version: 1,
5163 columns: table.wire_columns.clone(),
5164 blocks: vec![vec![ssp2::segment::SegmentRow {
5165 server_version: 2,
5166 values,
5167 }]],
5168 };
5169 assert!(
5170 matches!(client.apply_segment(0, &segment, false), Ok(1)),
5171 "{kind}"
5172 );
5173 }
5174 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
5175 let actual: String = client
5176 .conn
5177 .query_row(
5178 "SELECT title FROM tasks WHERE id=?1",
5179 [RowParam::Cell(&key)],
5180 |row| row.get(0),
5181 )
5182 .unwrap();
5183 assert_eq!(actual, expected, "{kind}, images={images}");
5184 }
5185 assert_eq!(client.overlay_rebuild_count.get(), 0);
5186 }
5187 }
5188 }
5189
5190 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
5191 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
5192 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
5193 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
5194 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
5195 })))});
5196 let mut client =
5197 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
5198 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();
5199 client.rebuild_overlay();
5200 client
5201 .mutate(vec![Mutation::Upsert {
5202 table: "tasks".into(),
5203 values: Map::from_iter([
5204 ("id".into(), json!("offline")),
5205 ("project_id".into(), json!("p1")),
5206 ("title".into(), json!("pending edit")),
5207 ]),
5208 base_version: None,
5209 }])
5210 .unwrap();
5211 let image = Connection::open_in_memory().unwrap();
5212 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
5213 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
5214 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();
5215 image
5216 .execute(
5217 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
5218 [rows],
5219 )
5220 .unwrap();
5221 client.overlay_rebuild_count.set(0);
5222 let table = client.schema.table("tasks").unwrap().clone();
5223 (client, image, table)
5224 }
5225
5226 #[test]
5227 fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
5228 let (mut client, image, table) = unique_import_fixture(1);
5229 image
5230 .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
5231 .unwrap();
5232 client
5233 .conn
5234 .execute_batch("PRAGMA max_page_count = 1")
5235 .unwrap();
5236 let effective = [("project_id".into(), vec!["p1".into()])];
5237 assert!(client
5238 .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
5239 .is_err());
5240 let failure = client.storage_failure.borrow().clone().unwrap();
5241 assert_eq!(failure.code, Some("client.storage_full"));
5242 assert_eq!(failure.sqlite_code, Some(13));
5243 assert!(!failure.message.contains("rollback"));
5244 assert!(client.conn.is_autocommit());
5245 client
5246 .conn
5247 .execute_batch("PRAGMA max_page_count = 1073741823")
5248 .unwrap();
5249 assert!(matches!(
5250 client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
5251 Ok(1)
5252 ));
5253 assert!(client.conn.is_autocommit());
5254 }
5255
5256 #[test]
5257 fn large_unique_import_copies_no_unrelated_tables() {
5258 let (mut client, image, table) = unique_import_fixture(10240);
5259 let pending = client.pending_commit_ids();
5260 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
5261 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
5262 assert!(matches!(
5263 client.apply_sqlite_image(
5264 &image,
5265 &table,
5266 true,
5267 &[("project_id".into(), vec!["p1".into()])],
5268 10240,
5269 7,
5270 "digest"
5271 ),
5272 Ok(10240)
5273 ));
5274 assert_eq!(client.overlay_rebuild_count.get(), 0);
5275 assert_eq!(client.pending_commit_ids(), pending);
5276 assert_eq!(
5277 client
5278 .query("SELECT count(*) AS n FROM tasks", &[])
5279 .unwrap()[0]["n"],
5280 10241
5281 );
5282 assert_eq!(
5283 client
5284 .query("SELECT count(*) AS n FROM tasks_fts", &[])
5285 .unwrap()[0]["n"],
5286 10241
5287 );
5288 assert_eq!(
5289 client
5290 .query("SELECT count(*) AS n FROM notes_fts", &[])
5291 .unwrap()[0]["n"],
5292 8192
5293 );
5294 }
5295
5296 #[test]
5297 #[ignore = "manual scaling benchmark; see bench/README.md"]
5298 fn benchmark_unique_image_import() {
5299 let mut samples = Vec::new();
5300 for rows in [10240, 51200, 102400] {
5301 for trial in 0..4 {
5302 let (mut client, image, table) = unique_import_fixture(rows);
5303 let start = std::time::Instant::now();
5304 assert!(
5305 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)
5306 );
5307 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
5308 assert_eq!(
5309 client
5310 .query("SELECT count(*) AS n FROM tasks", &[])
5311 .unwrap()[0]["n"],
5312 rows + 1
5313 );
5314 assert_eq!(
5315 client
5316 .query("SELECT count(*) AS n FROM tasks_fts", &[])
5317 .unwrap()[0]["n"],
5318 rows + 1
5319 );
5320 assert_eq!(
5321 client
5322 .query("SELECT count(*) AS n FROM notes_fts", &[])
5323 .unwrap()[0]["n"],
5324 8192
5325 );
5326 assert_eq!(client.pending_commit_ids().len(), 1);
5327 if trial > 0 {
5328 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
5329 }
5330 }
5331 }
5332 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});
5333 let output = serde_json::to_string_pretty(&artifact).unwrap();
5334 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
5335 use std::io::Write;
5336 let mut file = std::fs::OpenOptions::new()
5337 .write(true)
5338 .create_new(true)
5339 .open(path)
5340 .unwrap();
5341 file.write_all(output.as_bytes()).unwrap();
5342 } else {
5343 println!("{output}");
5344 }
5345 }
5346
5347 #[test]
5348 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
5349 let schema = json!({
5350 "version": 1,
5351 "tables": [{
5352 "name": "catalogue_codes",
5353 "primaryKey": "id",
5354 "columns": [
5355 { "name": "id", "type": "string", "nullable": false },
5356 { "name": "release_id", "type": "string", "nullable": false },
5357 { "name": "code", "type": "string", "nullable": false },
5358 { "name": "title", "type": "string", "nullable": false }
5359 ],
5360 "scopes": [{ "pattern": "release:{release_id}" }],
5361 "ftsIndexes": [{
5362 "name": "catalogue_codes_fts",
5363 "columns": ["code", "title"],
5364 "tokenize": "unicode61 remove_diacritics 2"
5365 }]
5366 }]
5367 });
5368 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
5369 .expect("FTS5 client");
5370 let insert_trigger: String = client
5371 .conn
5372 .query_row(
5373 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
5374 [],
5375 |row| row.get(0),
5376 )
5377 .expect("insert trigger");
5378 let replace_guard: String = client
5379 .conn
5380 .query_row(
5381 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
5382 [],
5383 |row| row.get(0),
5384 )
5385 .expect("replace guard");
5386 assert!(!insert_trigger.contains("DELETE FROM"));
5387 assert!(replace_guard.contains("BEFORE INSERT"));
5388 assert!(replace_guard.contains("WHEN EXISTS"));
5389 let search = |client: &SyncClient, query: &str| {
5390 client
5391 .query(
5392 "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",
5393 &[Value::from(query)],
5394 )
5395 .expect("FTS query")
5396 };
5397
5398 client
5399 .mutate(vec![Mutation::Upsert {
5400 table: "catalogue_codes".to_owned(),
5401 values: Map::from_iter([
5402 ("id".to_owned(), Value::from("c1")),
5403 ("release_id".to_owned(), Value::from("r1")),
5404 ("code".to_owned(), Value::from("A01")),
5405 ("title".to_owned(), Value::from("Cholera")),
5406 ]),
5407 base_version: None,
5408 }])
5409 .expect("insert code");
5410 assert_eq!(search(&client, "cholera").len(), 1);
5411 let prior = client
5412 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
5413 .unwrap();
5414 client
5415 .conn
5416 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
5417 .unwrap();
5418 client.create_synced_tables().unwrap();
5419 assert_eq!(
5420 client
5421 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
5422 .unwrap(),
5423 prior
5424 );
5425 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();
5426 let rendered = serde_json::to_string(&plan).unwrap();
5427 assert!(rendered.contains("COVERING INDEX"));
5428 assert!(rendered.contains("INDEX 0:="));
5429 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
5430 assert_eq!(search(&client, "cholera").len(), 1);
5431
5432 client
5433 .mutate(vec![Mutation::Upsert {
5434 table: "catalogue_codes".to_owned(),
5435 values: Map::from_iter([
5436 ("id".to_owned(), Value::from("c1")),
5437 ("release_id".to_owned(), Value::from("r1")),
5438 ("code".to_owned(), Value::from("A01")),
5439 ("title".to_owned(), Value::from("Enteric infection")),
5440 ]),
5441 base_version: None,
5442 }])
5443 .expect("update code");
5444 assert!(search(&client, "cholera").is_empty());
5445 assert_eq!(search(&client, "enteric").len(), 1);
5446
5447 client
5448 .mutate(vec![Mutation::Delete {
5449 table: "catalogue_codes".to_owned(),
5450 row_id: "c1".to_owned(),
5451 base_version: None,
5452 }])
5453 .expect("delete code");
5454 assert!(search(&client, "enteric").is_empty());
5455 }
5456
5457 #[test]
5458 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
5459 let schema = json!({
5460 "version": 1,
5461 "tables": [{
5462 "name": "patient_notes",
5463 "primaryKey": "id",
5464 "columns": [
5465 { "name": "id", "type": "string", "nullable": false },
5466 { "name": "practice_id", "type": "string", "nullable": false },
5467 { "name": "encryption_key_id", "type": "string", "nullable": false },
5468 { "name": "title", "type": "string", "nullable": false }
5469 ],
5470 "scopes": [{ "pattern": "practice:{practice_id}" }],
5471 "ftsIndexes": [{
5472 "name": "patient_notes_fts",
5473 "columns": ["title"],
5474 "tokenize": "unicode61 remove_diacritics 2"
5475 }]
5476 }]
5477 });
5478 let mut client = SyncClient::new(
5479 "local-purge-test".to_owned(),
5480 &schema,
5481 ClientLimits::default(),
5482 )
5483 .expect("local purge client");
5484 client
5485 .conn
5486 .execute(
5487 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
5488 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
5489 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
5490 [],
5491 )
5492 .expect("seed base rows");
5493 client.overlay_dirty.set(true);
5494 client.rebuild_overlay_if_dirty();
5495
5496 let note = |id: &str, key_id: &str, title: &str| {
5497 Map::from_iter([
5498 ("id".to_owned(), Value::from(id)),
5499 ("practice_id".to_owned(), Value::from("practice-1")),
5500 ("encryption_key_id".to_owned(), Value::from(key_id)),
5501 ("title".to_owned(), Value::from(title)),
5502 ])
5503 };
5504 let doomed = client
5505 .mutate(vec![
5506 Mutation::Upsert {
5507 table: "patient_notes".to_owned(),
5508 values: note("target", "key-revoked", "Target changed"),
5509 base_version: None,
5510 },
5511 Mutation::Upsert {
5512 table: "patient_notes".to_owned(),
5513 values: note("unrelated", "key-held", "Unrelated changed"),
5514 base_version: None,
5515 },
5516 ])
5517 .expect("doomed commit");
5518 let kept = client
5519 .mutate(vec![Mutation::Upsert {
5520 table: "patient_notes".to_owned(),
5521 values: note("kept", "key-held", "Kept optimistic"),
5522 base_version: None,
5523 }])
5524 .expect("kept commit");
5525 client.drain_change_batches();
5526
5527 let input = LocalDataPurgeInput {
5528 purge_id: "purge-001".to_owned(),
5529 targets: vec![LocalDataPurgeTarget {
5530 table: "patient_notes".to_owned(),
5531 selectors: BTreeMap::from([(
5532 "encryption_key_id".to_owned(),
5533 vec!["key-revoked".to_owned()],
5534 )]),
5535 }],
5536 };
5537 assert_eq!(
5538 client.purge_local_data(&input).expect("apply purge"),
5539 LocalDataPurgeResult {
5540 already_applied: false,
5541 purged_rows: 1,
5542 dropped_commits: 1,
5543 }
5544 );
5545 let rows = client
5546 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
5547 .expect("visible rows");
5548 assert_eq!(rows.len(), 2);
5549 assert_eq!(rows[0]["id"], "kept");
5550 assert_eq!(rows[1]["id"], "unrelated");
5551 assert_eq!(rows[1]["title"], "Unrelated original");
5552 let fts = client
5553 .query(
5554 "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'",
5555 &[],
5556 )
5557 .expect("fts query");
5558 assert!(fts.is_empty());
5559 assert_eq!(client.outbox.len(), 1);
5560 assert_eq!(client.outbox[0].client_commit_id, kept);
5561 let outcome = client
5562 .commit_outcome(&doomed)
5563 .expect("read doomed outcome")
5564 .expect("doomed outcome");
5565 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
5566 match &outcome.results[0] {
5567 CommitOperationOutcome::Error { rejection } => {
5568 assert_eq!(rejection.code, "client.local_data_purged");
5569 assert_eq!(rejection.client_commit_id, doomed);
5570 }
5571 other => panic!("expected local purge rejection, got {other:?}"),
5572 }
5573 assert_eq!(client.drain_change_batches().len(), 1);
5574 assert_eq!(
5575 client.purge_local_data(&input).expect("retry purge"),
5576 LocalDataPurgeResult {
5577 already_applied: true,
5578 purged_rows: 0,
5579 dropped_commits: 0,
5580 }
5581 );
5582 let conflicting = LocalDataPurgeInput {
5583 purge_id: input.purge_id.clone(),
5584 targets: vec![LocalDataPurgeTarget {
5585 table: "patient_notes".to_owned(),
5586 selectors: BTreeMap::from([(
5587 "encryption_key_id".to_owned(),
5588 vec!["key-held".to_owned()],
5589 )]),
5590 }],
5591 };
5592 assert!(client
5593 .purge_local_data(&conflicting)
5594 .expect_err("id collision must fail")
5595 .contains("already used with a different plan"));
5596 }
5597}
5598
5599impl SubState {
5600 fn name(self) -> &'static str {
5601 match self {
5602 SubState::Active => "active",
5603 SubState::Revoked => "revoked",
5604 SubState::Failed => "failed",
5605 }
5606 }
5607
5608 fn parse(value: &str) -> Self {
5609 match value {
5610 "revoked" => Self::Revoked,
5611 "failed" => Self::Failed,
5612 _ => Self::Active,
5613 }
5614 }
5615}
5616
5617#[derive(Debug, Clone)]
5618struct Subscription {
5619 id: String,
5620 table: String,
5621 requested: Vec<(String, Vec<String>)>,
5622 params: Option<String>,
5623 cursor: i64,
5624 bootstrap_state: Option<String>,
5626 state: SubState,
5627 reason_code: Option<String>,
5628 effective: Option<Vec<(String, Vec<String>)>>,
5631 synced_once: bool,
5632}
5633
5634#[derive(Debug, Clone)]
5635struct OutboxOp {
5636 upsert: bool,
5637 table: String,
5638 row_id: String,
5639 base_version: Option<i64>,
5640 values: Option<Map<String, Value>>,
5644}
5645
5646impl From<&OutboxOp> for CommitOperation {
5647 fn from(operation: &OutboxOp) -> Self {
5648 Self {
5649 table: operation.table.clone(),
5650 row_id: operation.row_id.clone(),
5651 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
5652 base_version: operation.base_version,
5653 values: operation.values.clone(),
5654 }
5655 }
5656}
5657
5658#[derive(Debug, Clone)]
5659struct OutboxCommit {
5660 client_commit_id: String,
5661 ops: Vec<OutboxOp>,
5662}
5663
5664fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
5668 commit
5669 .ops
5670 .iter()
5671 .filter(|operation| operation.upsert)
5672 .map(|operation| {
5673 (
5674 operation.table.as_str(),
5675 operation
5676 .values
5677 .as_ref()
5678 .map(|values| values.keys().map(String::as_str).collect())
5679 .unwrap_or_default(),
5680 )
5681 })
5682 .collect()
5683}
5684
5685type FailedCommit = (OutboxCommit, Map<String, Value>);
5686
5687#[derive(Debug, Clone)]
5688struct CompiledLocalDataPurgeTarget {
5689 table: String,
5690 selectors: Vec<(String, Vec<String>)>,
5691}
5692
5693struct StoredCommitOutcomeRow {
5694 sequence: i64,
5695 client_commit_id: String,
5696 status: String,
5697 recorded_at_ms: i64,
5698 results_json: String,
5699 operations_json: Option<String>,
5700 resolution: String,
5701 resolved_at_ms: Option<i64>,
5702 replacement_client_commit_id: Option<String>,
5703}
5704
5705enum SectionError {
5708 FailClosed,
5709 Abort(String, String),
5710}
5711
5712pub(crate) struct RequestMeta {
5713 pushed_ids: Vec<String>,
5714 fresh: Vec<(String, bool)>,
5717 pub(crate) accept: u8,
5718 deferred_commits: usize,
5722}
5723
5724#[derive(Default)]
5725struct ChangeAccumulator {
5726 tables: BTreeMap<String, Option<BTreeSet<String>>>,
5728 windows: BTreeMap<(String, String), BTreeSet<String>>,
5729 status: bool,
5730 conflicts: bool,
5731 rejections: bool,
5732 outcomes: bool,
5733}
5734
5735impl ChangeAccumulator {
5736 fn table(&mut self, table: &str) {
5737 self.tables.insert(table.to_owned(), None);
5738 }
5739
5740 fn scope(&mut self, table: &str, key: String) {
5741 match self.tables.get_mut(table) {
5742 Some(None) => {}
5743 Some(Some(keys)) => {
5744 keys.insert(key);
5745 }
5746 None => {
5747 self.tables
5748 .insert(table.to_owned(), Some(BTreeSet::from([key])));
5749 }
5750 }
5751 }
5752
5753 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
5754 self.windows
5755 .entry((base_key.to_owned(), table.to_owned()))
5756 .or_default()
5757 .insert(unit.to_owned());
5758 }
5759
5760 fn touched(&self) -> bool {
5761 !self.tables.is_empty()
5762 || !self.windows.is_empty()
5763 || self.status
5764 || self.conflicts
5765 || self.rejections
5766 || self.outcomes
5767 }
5768}
5769
5770const PUSH_OPS_PER_REQUEST: usize = 500;
5776const MAX_LOCAL_PURGE_TARGETS: usize = 64;
5777const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
5778const MAX_LOCAL_PURGE_VALUES: usize = 128;
5779const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
5780pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
5781
5782struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
5784
5785impl OverlayDirty {
5786 fn clean() -> Self {
5787 Self(RefCell::new(Some(BTreeSet::new())))
5788 }
5789 fn get(&self) -> bool {
5790 self.0
5791 .borrow()
5792 .as_ref()
5793 .is_none_or(|tables| !tables.is_empty())
5794 }
5795 fn set(&self, dirty: bool) {
5796 *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
5797 }
5798 fn table(&self, table: &str) {
5799 if let Some(tables) = self.0.borrow_mut().as_mut() {
5800 tables.insert(table.to_owned());
5801 }
5802 }
5803 fn snapshot(&self) -> Option<BTreeSet<String>> {
5804 self.0.borrow().clone()
5805 }
5806 fn restore(&self, tables: Option<BTreeSet<String>>) {
5807 *self.0.borrow_mut() = tables;
5808 }
5809}
5810
5811pub struct SyncClient {
5812 #[cfg(feature = "bench-internals")]
5813 benchmark_phases: Recorder,
5814 conn: Connection,
5815 schema: ClientSchema,
5816 client_id: String,
5817 limits: ClientLimits,
5818 subs: Vec<Subscription>,
5819 outbox: Vec<OutboxCommit>,
5820 failed_commits: Vec<FailedCommit>,
5821 retain_failed_commits: bool,
5822 conflicts: Vec<ConflictRecord>,
5823 rejections: Vec<RejectionRecord>,
5824 schema_floor: Option<SchemaFloor>,
5825 lease_state: Option<LeaseState>,
5827 stopped: bool,
5829 upgrading: bool,
5831 sync_needed: bool,
5833 realtime_policy: RealtimePolicy,
5836 realtime_state: RealtimeState,
5837 realtime_reason_code: Option<String>,
5838 realtime_retry_delay_ms: Option<u64>,
5839 presence: HashMap<String, HashMap<String, PresencePeer>>,
5841 now_ms: Option<i64>,
5844 encryption: crate::values::EncryptionConfig,
5848 security_preflight: bool,
5851 transport_enabled: bool,
5852 previous_version: Option<PreviousVersionContextConfig>,
5856 insert_sql: RefCell<HashMap<String, String>>,
5862 overlay_dirty: OverlayDirty,
5865 storage_failure: RefCell<Option<QueryReadFailure>>,
5867 #[cfg(test)]
5870 overlay_rebuild_count: Cell<usize>,
5871 #[cfg(test)]
5872 acknowledged_replay_count: Cell<usize>,
5873 #[cfg(test)]
5876 outcome_prune_count: Cell<usize>,
5877 progress: ProgressObserver,
5879 change_queue: VecDeque<ClientChangeBatch>,
5880 sync_intent_queue: VecDeque<SyncIntent>,
5881 retry_delay_ms: u64,
5883 round_retry_delay_ms: Option<u64>,
5885 active_round: Option<uuid::Uuid>,
5886 last_round: Option<DiagnosticLastRound>,
5887 last_change: Option<DiagnosticLastChange>,
5888 query_failures: Vec<DiagnosticQueryFailure>,
5890}
5891
5892pub(crate) fn quote_ident(name: &str) -> String {
5893 format!("\"{}\"", name.replace('"', "\"\""))
5894}
5895pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
5899 conn.query_row(
5900 "SELECT value FROM _syncular_meta WHERE key = ?1",
5901 rusqlite::params![key],
5902 |row| row.get::<_, String>(0),
5903 )
5904 .ok()
5905}
5906
5907pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
5908 let _ = conn.execute(
5909 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
5910 rusqlite::params![key, value],
5911 );
5912}
5913
5914pub(crate) fn meta_delete(conn: &Connection, key: &str) {
5915 let _ = conn.execute(
5916 "DELETE FROM _syncular_meta WHERE key = ?1",
5917 rusqlite::params![key],
5918 );
5919}
5920
5921fn row_id_predicate(table: &TableSchema) -> String {
5923 let key = quote_ident(&table.primary_key);
5924 let text_match = format!("CAST({key} AS TEXT) = ?1");
5925 match table.columns[table.pk_index].ty {
5926 ColumnType::String | ColumnType::Json => {
5927 format!("{key} = ?1 AND {text_match}")
5928 }
5929 ColumnType::Integer | ColumnType::Boolean => {
5933 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
5934 }
5935 _ => text_match,
5938 }
5939}
5940
5941fn is_local_operation_code_like(value: &str) -> bool {
5942 let bytes = value.as_bytes();
5943 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
5944 && bytes
5945 .iter()
5946 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
5947}
5948
5949fn base_table(name: &str) -> String {
5950 quote_ident(&format!("_syncular_base_{name}"))
5951}
5952
5953type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
5958
5959fn window_base_key(base: &WindowBase) -> String {
5960 format!(
5961 "{}\0{}\0{}",
5962 base.table,
5963 base.variable,
5964 canonical_scope_json(&base.fixed_scopes)
5965 )
5966}
5967
5968fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
5970 let mut scopes = base.fixed_scopes.clone();
5971 scopes.retain(|(k, _)| k != &base.variable);
5972 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
5973 scopes
5974}
5975
5976fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
5980 let canonical = canonical_scope_json(&unit_scopes(base, unit));
5981 let digest = Sha256::digest(canonical.as_bytes());
5982 let hex = bytes_to_hex(&digest);
5983 format!("w:{}:{}", base.table, &hex[..16])
5984}
5985
5986fn visible_table(name: &str) -> String {
5987 quote_ident(name)
5988}
5989
5990const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
5991
5992fn is_synced_table_name(name: &str) -> bool {
5995 if name.starts_with("sqlite_") {
5996 return false;
5997 }
5998 if name.starts_with("_syncular_base_") {
5999 return true; }
6001 !name.starts_with("_syncular_")
6003}
6004
6005fn blob_id_for(bytes: &[u8]) -> String {
6007 let digest = Sha256::digest(bytes);
6008 format!("sha256:{}", bytes_to_hex(&digest))
6009}
6010
6011enum RowParam<'a> {
6015 Cell(&'a Option<ColumnValue>),
6016 Version(i64),
6017}
6018
6019impl rusqlite::ToSql for RowParam<'_> {
6020 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
6021 Ok(match self {
6022 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
6023 RowParam::Cell(cell) => match cell {
6024 None => ToSqlOutput::Owned(SqlValue::Null),
6025 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
6026 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
6027 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
6028 Some(ColumnValue::Boolean(b)) => {
6029 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
6030 }
6031 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
6032 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
6033 }
6034 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
6036 ToSqlOutput::Borrowed(ValueRef::Blob(b))
6037 }
6038 },
6039 })
6040 }
6041}
6042
6043fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
6045 match param.to_sql().map_err(|error| error.to_string())? {
6046 ToSqlOutput::Owned(value) => Ok(value),
6047 ToSqlOutput::Borrowed(value) => {
6048 SqlValue::try_from(value).map_err(|error| error.to_string())
6049 }
6050 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
6051 }
6052}
6053
6054fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
6062 use ssp2::segment::ColumnType;
6063 let mismatch = || {
6064 Err(format!(
6065 "image column {:?} holds a value of the wrong type",
6066 column.name
6067 ))
6068 };
6069 match value {
6070 ValueRef::Null => {
6071 if !column.nullable {
6072 return Err(format!(
6073 "image column {:?} is NULL but not nullable",
6074 column.name
6075 ));
6076 }
6077 Ok(ToSqlOutput::Owned(SqlValue::Null))
6078 }
6079 ValueRef::Integer(i) => match column.ty {
6080 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
6081 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
6082 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
6083 _ => mismatch(),
6084 },
6085 ValueRef::Real(_) => match column.ty {
6086 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
6087 _ => mismatch(),
6088 },
6089 ValueRef::Text(t) => {
6090 std::str::from_utf8(t)
6091 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
6092 match column.ty {
6093 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
6094 Ok(ToSqlOutput::Borrowed(value))
6095 }
6096 _ => mismatch(),
6097 }
6098 }
6099 ValueRef::Blob(_) => match column.ty {
6100 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
6102 _ => mismatch(),
6103 },
6104 }
6105}
6106
6107pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
6108 use rusqlite::types::ValueRef;
6109 match value {
6110 ValueRef::Null => Value::Null,
6111 ValueRef::Integer(i) => match column.ty {
6112 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
6113 ssp2::segment::ColumnType::Float => {
6114 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
6115 }
6116 _ => Value::from(i),
6117 },
6118 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
6119 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
6120 ValueRef::Blob(b) => {
6121 let mut map = Map::new();
6122 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
6123 Value::Object(map)
6124 }
6125 }
6126}
6127
6128fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
6132 Ok(match value {
6133 Value::Null => SqlValue::Null,
6134 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
6135 Value::Number(n) => {
6136 if let Some(i) = n.as_i64() {
6137 SqlValue::Integer(i)
6138 } else if let Some(f) = n.as_f64() {
6139 SqlValue::Real(f)
6140 } else {
6141 return Err(format!("query param number {n} is out of range"));
6142 }
6143 }
6144 Value::String(s) => SqlValue::Text(s.clone()),
6145 Value::Object(_) => {
6146 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
6147 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
6148 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
6149 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
6150 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
6151 })?)
6152 } else {
6153 return Err(
6154 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
6155 .to_owned(),
6156 );
6157 }
6158 }
6159 Value::Array(_) => return Err("query array params are not supported".to_owned()),
6160 })
6161}
6162
6163fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
6168 use rusqlite::types::ValueRef;
6169 match value {
6170 ValueRef::Null => Value::Null,
6171 ValueRef::Integer(i) => {
6172 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
6176 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
6177 Value::from(i)
6178 } else {
6179 let mut map = Map::new();
6180 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
6181 Value::Object(map)
6182 }
6183 }
6184 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
6185 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
6186 ValueRef::Blob(b) => {
6187 let mut map = Map::new();
6188 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
6189 Value::Object(map)
6190 }
6191 }
6192}
6193
6194fn query_connection(
6196 conn: &Connection,
6197 sql: &str,
6198 params: &[QueryValue],
6199) -> Result<Vec<QueryRow>, QueryReadFailure> {
6200 crate::query_guard::assert_read_only_query(sql)?;
6201 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
6202 let bound: Vec<SqlValue> = params
6203 .iter()
6204 .map(json_param_to_sql)
6205 .collect::<Result<_, _>>()?;
6206 let mut stmt = conn.prepare(&lowered_sql)?;
6207 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
6208 let bound_refs: Vec<&dyn rusqlite::ToSql> =
6209 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
6210 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
6211 let mut out = Vec::new();
6212 while let Some(row) = sql_rows.next()? {
6213 let mut record = Map::new();
6214 for (i, name) in column_names.iter().enumerate() {
6215 let value = row.get_ref(i)?;
6216 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
6217 }
6218 out.push(record);
6219 }
6220 Ok(out)
6221}
6222
6223fn persisted_window_state(
6224 conn: &Connection,
6225 base: &WindowBase,
6226) -> Result<WindowState, QueryReadFailure> {
6227 let mut stmt = conn.prepare(
6228 "SELECT windows.unit, subscriptions.state_json
6229 FROM _syncular_windows AS windows
6230 JOIN _syncular_subscriptions AS subscriptions
6231 ON subscriptions.id = windows.sub_id
6232 WHERE windows.base = ?1
6233 ORDER BY windows.unit ASC",
6234 )?;
6235 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
6236 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6237 })?;
6238 let mut units = Vec::new();
6239 let mut pending = Vec::new();
6240 for row in rows {
6241 let (unit, raw) = row?;
6242 let state: Value = serde_json::from_str(&raw)
6243 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
6244 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
6245 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
6246 || state
6247 .get("bootstrapState")
6248 .is_some_and(|value| !value.is_null());
6249 if is_pending {
6250 pending.push(unit.clone());
6251 }
6252 units.push(unit);
6253 }
6254 Ok(WindowState { units, pending })
6255}
6256
6257fn snapshot_connection(
6258 conn: &Connection,
6259 sql: &str,
6260 params: &[Value],
6261 coverage: &[WindowCoverage],
6262) -> Result<QuerySnapshot, QueryReadFailure> {
6263 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
6264 let result: Result<QuerySnapshot, QueryReadFailure> = (|| {
6265 let revision = conn
6266 .query_row(
6267 "SELECT value FROM _syncular_meta WHERE key = ?1",
6268 rusqlite::params![LOCAL_REVISION_KEY],
6269 |row| row.get::<_, String>(0),
6270 )
6271 .ok()
6272 .and_then(|value| value.parse::<u64>().ok())
6273 .unwrap_or(0);
6274 let rows = query_connection(conn, sql, params)?;
6275 let mut pending = Vec::new();
6276 let mut missing = Vec::new();
6277 for requested in coverage {
6278 let base_key = window_base_key(&requested.base);
6279 let state = persisted_window_state(conn, &requested.base)?;
6280 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
6281 let reference = WindowUnitRef {
6282 base_key: base_key.clone(),
6283 unit: unit.clone(),
6284 };
6285 if !state.units.iter().any(|held| held == &unit) {
6286 missing.push(reference);
6287 } else if state.pending.iter().any(|held| held == &unit) {
6288 pending.push(reference);
6289 }
6290 }
6291 }
6292 Ok(QuerySnapshot {
6293 revision: revision.to_string(),
6294 rows,
6295 coverage: CoverageSnapshot {
6296 complete: pending.is_empty() && missing.is_empty(),
6297 pending,
6298 missing,
6299 },
6300 })
6301 })();
6302 match result {
6303 Ok(snapshot) => {
6304 conn.execute_batch("RELEASE syncular_snapshot_read")?;
6305 Ok(snapshot)
6306 }
6307 Err(mut error) => {
6308 if let Err(rollback) = conn
6309 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
6310 {
6311 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
6312 }
6313 Err(error)
6314 }
6315 }
6316}
6317
6318pub struct FileQuerySnapshotReader {
6323 path: String,
6324 conn: Option<Connection>,
6325}
6326
6327impl FileQuerySnapshotReader {
6328 #[must_use]
6329 pub fn new(path: impl Into<String>) -> Self {
6330 Self {
6331 path: path.into(),
6332 conn: None,
6333 }
6334 }
6335
6336 fn connection(&mut self) -> Result<&Connection, String> {
6337 if self.conn.is_none() {
6338 let conn = Connection::open_with_flags(
6339 &self.path,
6340 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
6341 )
6342 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
6343 conn.busy_timeout(std::time::Duration::from_millis(250))
6344 .map_err(|error| error.to_string())?;
6345 self.conn = Some(conn);
6346 }
6347 self.conn
6348 .as_ref()
6349 .ok_or_else(|| "read sidecar connection missing".to_owned())
6350 }
6351
6352 pub fn query_snapshot(
6355 &mut self,
6356 sql: &str,
6357 params: &[Value],
6358 coverage: &[WindowCoverage],
6359 ) -> Result<QuerySnapshot, QueryReadFailure> {
6360 snapshot_connection(self.connection()?, sql, params, coverage)
6361 }
6362}
6363
6364fn validate_authority_reads(
6365 schema: &ClientSchema,
6366 declarations: &[crate::api::AuthorityReadDeclaration],
6367) -> Result<(), String> {
6368 let mut seen = BTreeSet::new();
6369 for read in declarations {
6370 let table = schema
6371 .table(&read.table)
6372 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
6373 let plain = |name: &str| {
6374 table.columns.iter().enumerate().any(|(i, c)| {
6375 c.name == name
6376 && name != "authority_version"
6377 && !name.starts_with("_sync")
6378 && !table.encrypted_columns.iter().any(|e| e.index == i)
6379 && matches!(
6380 c.ty,
6381 ssp2::segment::ColumnType::String
6382 | ssp2::segment::ColumnType::Integer
6383 | ssp2::segment::ColumnType::Float
6384 | ssp2::segment::ColumnType::Boolean
6385 | ssp2::segment::ColumnType::Json
6386 )
6387 })
6388 };
6389 if !seen.insert(&read.table)
6390 || read.columns.is_empty()
6391 || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
6392 || !read.columns.contains(&table.primary_key)
6393 || !read.columns.iter().all(|c| plain(c))
6394 || read.scopes.is_empty()
6395 || !read.scopes.iter().all(|(v, values)| {
6396 table
6397 .scope_column(v)
6398 .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
6399 && !values.is_empty()
6400 && values.iter().all(|v| !v.is_empty() && v != "*")
6401 })
6402 {
6403 return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
6404 }
6405 }
6406 Ok(())
6407}
6408
6409impl SyncClient {
6410 pub fn new_with_identity(
6411 client_id: Option<String>,
6412 schema_json: &Value,
6413 limits: ClientLimits,
6414 ) -> Result<Self, String> {
6415 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
6416 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
6417 Self::with_connection(resolved, schema_json, limits, conn)
6418 }
6419
6420 pub fn new(
6421 client_id: String,
6422 schema_json: &Value,
6423 limits: ClientLimits,
6424 ) -> Result<Self, String> {
6425 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
6426 Self::with_connection(client_id, schema_json, limits, conn)
6427 }
6428
6429 pub fn open_path(
6435 client_id: String,
6436 schema_json: &Value,
6437 limits: ClientLimits,
6438 path: &str,
6439 ) -> Result<Self, String> {
6440 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
6441 Self::with_connection(client_id, schema_json, limits, conn)
6442 }
6443
6444 pub fn open_path_with_identity(
6445 client_id: Option<String>,
6446 schema_json: &Value,
6447 limits: ClientLimits,
6448 path: &str,
6449 ) -> Result<Self, String> {
6450 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
6451 conn.busy_timeout(std::time::Duration::from_millis(250))
6457 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
6458 conn.pragma_update(None, "journal_mode", "WAL")
6459 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
6460 let persisted = conn
6461 .query_row(
6462 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
6463 [],
6464 |row| row.get::<_, String>(0),
6465 )
6466 .ok();
6467 let resolved = persisted
6468 .clone()
6469 .or(client_id.clone())
6470 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
6471 if let (Some(existing), Some(requested)) = (persisted, client_id) {
6472 if existing != requested {
6473 return Err(format!(
6474 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
6475 ));
6476 }
6477 }
6478 Self::with_connection(resolved, schema_json, limits, conn)
6479 }
6480
6481 #[must_use]
6482 pub fn client_id(&self) -> &str {
6483 &self.client_id
6484 }
6485
6486 pub fn with_connection(
6493 client_id: String,
6494 schema_json: &Value,
6495 limits: ClientLimits,
6496 conn: Connection,
6497 ) -> Result<Self, String> {
6498 if limits.outcome_retention_max_entries == Some(0) {
6499 return Err(
6500 "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
6501 );
6502 }
6503 let schema = parse_schema_json(schema_json)?;
6504 validate_authority_reads(&schema, &limits.authority_reads)?;
6505 let previous_version = match limits.previous_version_context {
6508 Some(config) => {
6509 config.validate()?;
6510 Some(config)
6511 }
6512 None => None,
6513 };
6514 let mut client = SyncClient {
6515 #[cfg(feature = "bench-internals")]
6516 benchmark_phases: Recorder::default(),
6517 conn,
6518 schema,
6519 client_id,
6520 limits,
6521 subs: Vec::new(),
6522 outbox: Vec::new(),
6523 failed_commits: Vec::new(),
6524 retain_failed_commits: false,
6525 conflicts: Vec::new(),
6526 rejections: Vec::new(),
6527 schema_floor: None,
6528 lease_state: None,
6529 stopped: false,
6530 upgrading: false,
6531 sync_needed: false,
6532 realtime_policy: RealtimePolicy::Optional,
6533 realtime_state: RealtimeState::Disconnected,
6534 realtime_reason_code: None,
6535 realtime_retry_delay_ms: None,
6536 presence: HashMap::new(),
6537 now_ms: None,
6538 encryption: crate::values::EncryptionConfig::default(),
6539 security_preflight: false,
6540 transport_enabled: true,
6541 previous_version,
6542 insert_sql: RefCell::new(HashMap::new()),
6543 overlay_dirty: OverlayDirty::clean(),
6544 storage_failure: RefCell::new(None),
6545 #[cfg(test)]
6546 overlay_rebuild_count: Cell::new(0),
6547 #[cfg(test)]
6548 acknowledged_replay_count: Cell::new(0),
6549 #[cfg(test)]
6550 outcome_prune_count: Cell::new(0),
6551 progress: ProgressObserver::default(),
6552 change_queue: VecDeque::new(),
6553 sync_intent_queue: VecDeque::new(),
6554 retry_delay_ms: 250,
6555 round_retry_delay_ms: None,
6556 active_round: None,
6557 last_round: None,
6558 last_change: None,
6559 query_failures: Vec::new(),
6560 };
6561 client
6565 .conn
6566 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
6567 client.create_bookkeeping_tables()?;
6571 match client.get_meta(CLIENT_ID_KEY) {
6572 Some(existing) if existing != client.client_id => {
6573 return Err(format!(
6574 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
6575 client.client_id
6576 ));
6577 }
6578 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
6579 _ => {}
6580 }
6581 client.restore_persisted_state()?;
6582 client.failed_commits = client.load_failed_commits()?;
6583 let marker = client
6584 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6585 .and_then(|value| value.parse::<i32>().ok());
6586 match marker {
6587 None => {
6588 client.create_synced_tables()?;
6589 client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
6590 set_local_schema_descriptor(&client.conn, &client.schema);
6592 client.save_subscription_scope_schema();
6593 }
6594 Some(version) if version == client.schema.version => {
6595 client.create_synced_tables()?;
6596 set_local_schema_descriptor(&client.conn, &client.schema);
6600 client.save_subscription_scope_schema();
6601 }
6602 Some(_) => client.run_schema_reset()?,
6603 }
6604 client.reconcile_previous_version_at_boot()?;
6610 client.clear_satisfied_persisted_schema_floor();
6611 client.prune_unknown_subscriptions(false)?;
6612 if marker == Some(client.schema.version)
6613 && (!client.outbox.is_empty()
6614 || !client.failed_commits.is_empty()
6615 || client.has_acknowledged_rows())
6616 {
6617 client.overlay_dirty.set(true);
6620 client.rebuild_overlay();
6621 }
6622 client.enqueue_startup_sync_if_needed()?;
6628 Ok(client)
6629 }
6630
6631 pub fn set_now_ms(&mut self, now_ms: i64) {
6634 self.now_ms = Some(now_ms);
6635 }
6636
6637 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
6641 self.encryption = encryption;
6642 }
6643
6644 #[must_use]
6645 pub fn security_lifecycle(&self) -> &'static str {
6646 if self.security_preflight {
6647 "preflight"
6648 } else {
6649 "active"
6650 }
6651 }
6652
6653 #[must_use]
6654 pub fn security_preflight(&self) -> bool {
6655 self.security_preflight
6656 }
6657
6658 pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
6660 if self.transport_enabled != enabled {
6661 self.transport_enabled = enabled;
6662 self.sync_intent_queue.clear();
6663 self.sync_intent_queue
6664 .push_back(if enabled && !self.security_preflight {
6665 SyncIntent::Interactive
6666 } else {
6667 SyncIntent::None
6668 });
6669 }
6670 if !enabled && self.active_round.is_none() {
6672 self.disconnect_realtime(transport);
6673 }
6674 }
6675
6676 pub fn transport_enabled(&self) -> bool {
6677 self.transport_enabled
6678 }
6679
6680 pub fn begin_security_preflight(&mut self) {
6687 self.seal_security_on_teardown();
6688 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
6691 }
6692
6693 pub fn seal_security_on_teardown(&mut self) {
6703 self.cancel_sync_round();
6704 self.security_preflight = true;
6705 self.encryption = crate::values::EncryptionConfig::default();
6706 self.sync_intent_queue.clear();
6707 }
6708
6709 pub fn activate_security(
6711 &mut self,
6712 encryption: crate::values::EncryptionConfig,
6713 ) -> Result<(), String> {
6714 if !self.security_preflight {
6715 return Err(
6716 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
6717 );
6718 }
6719 self.enqueue_startup_sync_if_needed()?;
6720 self.encryption = encryption;
6721 self.security_preflight = false;
6722 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
6723 Ok(())
6724 }
6725
6726 fn clock_now_ms(&self) -> i64 {
6727 self.now_ms.unwrap_or_else(|| {
6728 std::time::SystemTime::now()
6729 .duration_since(std::time::UNIX_EPOCH)
6730 .map(|d| d.as_millis() as i64)
6731 .unwrap_or(0)
6732 })
6733 }
6734
6735 fn create_bookkeeping_tables(&self) -> Result<(), String> {
6736 self.conn
6738 .execute_batch(
6739 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
6740 seq INTEGER PRIMARY KEY AUTOINCREMENT,
6741 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
6742 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
6743 tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
6744 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
6745 commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
6746 commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
6747 PRIMARY KEY(commit_id, idx));
6748 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
6749 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
6750 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
6751 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
6752 seq INTEGER PRIMARY KEY AUTOINCREMENT,
6753 client_commit_id TEXT NOT NULL UNIQUE,
6754 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
6755 recorded_at_ms INTEGER NOT NULL,
6756 results_json TEXT NOT NULL,
6757 operations_json TEXT,
6758 resolution TEXT NOT NULL DEFAULT 'active'
6759 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
6760 resolved_at_ms INTEGER,
6761 replacement_client_commit_id TEXT);
6762 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
6763 ON _syncular_commit_outcomes(resolution, seq);
6764 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
6765 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
6766 CREATE TABLE IF NOT EXISTS _syncular_meta (
6767 key TEXT PRIMARY KEY, value TEXT NOT NULL);
6768 CREATE TABLE IF NOT EXISTS _syncular_windows (
6769 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
6770 PRIMARY KEY (base, unit));
6771 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
6772 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
6773 effective_scopes TEXT NOT NULL);
6774 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
6775 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
6776 PRIMARY KEY(commit_id, blob_id));
6777 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
6778 ON _syncular_blob_commit_refs(blob_id);",
6779 )
6780 .map_err(|e| e.to_string())?;
6781 let _ = self
6784 .conn
6785 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
6786 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
6787 self.set_meta(LOCAL_REVISION_KEY, "0");
6788 }
6789 if self.schema_has_blobs() {
6792 let exists = self
6793 .conn
6794 .query_row(
6795 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
6796 [],
6797 |row| row.get::<_, bool>(0),
6798 )
6799 .map_err(|e| e.to_string())?;
6800 if exists {
6801 let mut statement = self
6802 .conn
6803 .prepare("PRAGMA table_info(_syncular_blobs)")
6804 .map_err(|e| e.to_string())?;
6805 let mut columns = statement
6806 .query_map([], |row| row.get::<_, String>(1))
6807 .map_err(|e| e.to_string())?
6808 .collect::<Result<Vec<_>, _>>()
6809 .map_err(|e| e.to_string())?;
6810 columns.sort();
6811 let mut expected = vec![
6812 "blob_id",
6813 "byte_length",
6814 "bytes",
6815 "created_at_ms",
6816 "media_type",
6817 ];
6818 expected.sort_unstable();
6819 if columns != expected {
6820 return Err(
6821 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
6822 );
6823 }
6824 } else {
6825 self.conn
6826 .execute_batch(
6827 "CREATE TABLE _syncular_blobs(
6828 blob_id TEXT PRIMARY KEY,
6829 bytes BLOB NOT NULL,
6830 byte_length INTEGER NOT NULL,
6831 media_type TEXT,
6832 created_at_ms INTEGER NOT NULL);",
6833 )
6834 .map_err(|e| e.to_string())?;
6835 }
6836 self.conn
6837 .execute_batch(
6838 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
6839 blob_id TEXT PRIMARY KEY,
6840 media_type TEXT,
6841 created_at_ms INTEGER NOT NULL);",
6842 )
6843 .map_err(|e| e.to_string())?;
6844 }
6845 Ok(())
6846 }
6847
6848 fn schema_has_blobs(&self) -> bool {
6850 self.schema
6851 .tables
6852 .iter()
6853 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
6854 }
6855
6856 fn get_meta(&self, key: &str) -> Option<String> {
6859 meta_get(&self.conn, key)
6860 }
6861
6862 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
6863 self.conn
6864 .query_row(
6865 "SELECT value FROM _syncular_meta WHERE key = ?1",
6866 rusqlite::params![key],
6867 |row| row.get::<_, String>(0),
6868 )
6869 .optional()
6870 .map_err(|_| {
6871 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
6872 })
6873 }
6874
6875 fn set_meta(&self, key: &str, value: &str) {
6876 meta_set(&self.conn, key, value);
6877 }
6878
6879 fn delete_meta(&self, key: &str) {
6880 meta_delete(&self.conn, key);
6881 }
6882
6883 fn restore_persisted_state(&mut self) -> Result<(), String> {
6884 self.subs = {
6885 let mut stmt = self
6886 .conn
6887 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
6888 .map_err(|error| error.to_string())?;
6889 let rows = stmt
6890 .query_map([], |row| {
6891 Ok((
6892 row.get::<_, String>(0)?,
6893 row.get::<_, String>(1)?,
6894 row.get::<_, String>(2)?,
6895 ))
6896 })
6897 .map_err(|error| error.to_string())?;
6898 let mut subscriptions = Vec::new();
6899 for row in rows {
6900 let (id, table, raw) = row.map_err(|error| error.to_string())?;
6901 let state: Value = serde_json::from_str(&raw)
6902 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
6903 let requested = json_to_scope_map(
6904 state.get("requested").unwrap_or(&Value::Object(Map::new())),
6905 )?;
6906 let effective = state
6907 .get("effectiveScopes")
6908 .filter(|value| !value.is_null())
6909 .map(json_to_scope_map)
6910 .transpose()?;
6911 subscriptions.push(Subscription {
6912 id,
6913 table,
6914 requested,
6915 params: state
6916 .get("params")
6917 .and_then(Value::as_str)
6918 .map(str::to_owned),
6919 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
6920 bootstrap_state: state
6921 .get("bootstrapState")
6922 .and_then(Value::as_str)
6923 .map(str::to_owned),
6924 state: SubState::parse(
6925 state
6926 .get("status")
6927 .and_then(Value::as_str)
6928 .unwrap_or("active"),
6929 ),
6930 reason_code: state
6931 .get("reasonCode")
6932 .and_then(Value::as_str)
6933 .map(str::to_owned),
6934 effective,
6935 synced_once: state
6936 .get("syncedOnce")
6937 .and_then(Value::as_bool)
6938 .unwrap_or(false),
6939 });
6940 }
6941 subscriptions
6942 };
6943
6944 self.outbox = {
6945 let mut stmt = self
6946 .conn
6947 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
6948 .map_err(|error| error.to_string())?;
6949 let rows = stmt
6950 .query_map([], |row| {
6951 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6952 })
6953 .map_err(|error| error.to_string())?;
6954 let mut commits = Vec::new();
6955 for row in rows {
6956 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
6957 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
6958 format!("invalid persisted outbox {client_commit_id:?}: {error}")
6959 })?;
6960 let mut ops = Vec::with_capacity(entries.len());
6961 for entry in entries {
6962 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
6963 ops.push(OutboxOp {
6964 upsert: op == "upsert",
6965 table: entry
6966 .get("table")
6967 .and_then(Value::as_str)
6968 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
6969 .to_owned(),
6970 row_id: entry
6971 .get("rowId")
6972 .and_then(Value::as_str)
6973 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
6974 .to_owned(),
6975 base_version: entry.get("baseVersion").and_then(Value::as_i64),
6976 values: entry.get("values").and_then(Value::as_object).cloned(),
6979 });
6980 }
6981 commits.push(OutboxCommit {
6982 client_commit_id,
6983 ops,
6984 });
6985 }
6986 commits
6987 };
6988
6989 self.prune_commit_outcomes()?;
6990 let active = self.commit_outcomes(CommitOutcomeQuery {
6991 active_only: true,
6992 ..CommitOutcomeQuery::default()
6993 })?;
6994 self.conflicts = active
6995 .iter()
6996 .flat_map(|outcome| outcome.results.iter())
6997 .filter_map(|result| match result {
6998 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
6999 _ => None,
7000 })
7001 .collect();
7002 self.rejections = active
7003 .iter()
7004 .flat_map(|outcome| outcome.results.iter())
7005 .filter_map(|result| match result {
7006 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
7007 _ => None,
7008 })
7009 .collect();
7010
7011 self.lease_state = self
7012 .get_meta(LEASE_STATE_KEY)
7013 .map(|raw| serde_json::from_str(&raw))
7014 .transpose()
7015 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
7016 self.schema_floor = self
7017 .get_meta(SCHEMA_FLOOR_KEY)
7018 .map(|raw| serde_json::from_str(&raw))
7019 .transpose()
7020 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
7021 self.stopped = self.schema_floor.is_some();
7022 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
7025 self.security_preflight = true;
7026 }
7027 Ok(())
7028 }
7029
7030 fn save_subscription_scope_schema(&self) {
7031 let declarations: BTreeMap<_, BTreeMap<_, _>> = self
7032 .schema
7033 .tables
7034 .iter()
7035 .map(|table| {
7036 (
7037 table.name.clone(),
7038 table
7039 .scope_variables
7040 .iter()
7041 .map(|scope| {
7042 (
7043 scope.variable.clone(),
7044 [scope.prefix.clone(), scope.column.clone()],
7045 )
7046 })
7047 .collect(),
7048 )
7049 })
7050 .collect();
7051 self.set_meta(
7052 "subscriptionScopeSchema",
7053 &serde_json::to_string(&declarations).expect("scope schema serialization"),
7054 );
7055 }
7056
7057 fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
7059 let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
7060 self.get_meta("subscriptionScopeSchema")
7061 .map(|raw| serde_json::from_str(&raw))
7062 .transpose()
7063 .map_err(|error| format!("invalid persisted scope schema: {error}"))?
7064 } else {
7065 None
7066 };
7067 let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
7068 let Some(table) = self.schema.table(table_name) else {
7069 return false;
7070 };
7071 if requested
7072 .iter()
7073 .any(|(variable, _)| table.scope_column(variable).is_none())
7074 {
7075 return false;
7076 }
7077 if !bump {
7078 return true;
7079 }
7080 let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
7081 return false;
7082 };
7083 let variables: BTreeSet<_> = if requested.is_empty() {
7084 old.keys()
7085 .cloned()
7086 .chain(
7087 table
7088 .scope_variables
7089 .iter()
7090 .map(|scope| scope.variable.clone()),
7091 )
7092 .collect()
7093 } else {
7094 requested
7095 .iter()
7096 .map(|(variable, _)| variable.clone())
7097 .collect()
7098 };
7099 variables.iter().all(|variable| {
7100 let current = table
7101 .scope_variables
7102 .iter()
7103 .find(|scope| &scope.variable == variable);
7104 match (old.get(variable), current) {
7105 (Some(old), Some(current)) => {
7106 old == &[current.prefix.clone(), current.column.clone()]
7107 }
7108 _ => false,
7109 }
7110 })
7111 };
7112 let mut stale_ids: BTreeSet<_> = self
7113 .subs
7114 .iter()
7115 .filter(|sub| !compatible(&sub.table, &sub.requested))
7116 .map(|sub| sub.id.clone())
7117 .collect();
7118 for (id, table, effective) in self.load_pending_evictions()? {
7119 if !compatible(&table, &effective) {
7120 stale_ids.insert(id);
7121 }
7122 }
7123 for id in &stale_ids {
7124 for sql in [
7125 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
7126 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
7127 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7128 ] {
7129 self.conn
7130 .execute(sql, rusqlite::params![id])
7131 .map_err(|error| error.to_string())?;
7132 }
7133 }
7134 self.subs.retain(|sub| !stale_ids.contains(&sub.id));
7135 Ok(())
7136 }
7137
7138 #[must_use]
7139 pub fn local_revision(&self) -> u64 {
7140 self.get_meta(LOCAL_REVISION_KEY)
7141 .and_then(|value| value.parse().ok())
7142 .unwrap_or(0)
7143 }
7144
7145 #[must_use]
7146 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
7147 SyncStatusSnapshot {
7148 current_schema_version: self.schema.version,
7149 outbox: self.outbox.len(),
7150 upgrading: self.upgrading,
7151 lease_state: self.lease_state.clone(),
7152 schema_floor: self.schema_floor.clone(),
7153 sync_needed: self.sync_needed,
7154 previous_version_context: self.previous_version_status(),
7155 }
7156 }
7157
7158 fn previous_version_status(&self) -> PreviousVersionStatus {
7161 let Some(replica_path) = self.previous_version_replica_path() else {
7162 return PreviousVersionStatus {
7163 present: false,
7164 created_at_ms: None,
7165 };
7166 };
7167 match crate::previous_version::read_previous_version_record(&replica_path) {
7168 Ok(Some(record)) => PreviousVersionStatus {
7169 present: true,
7170 created_at_ms: Some(record.created_at_ms),
7171 },
7172 _ => PreviousVersionStatus {
7173 present: std::path::Path::new(
7174 &crate::previous_version::previous_version_container_path(&replica_path),
7175 )
7176 .exists(),
7177 created_at_ms: None,
7178 },
7179 }
7180 }
7181
7182 pub fn previous_version_snapshot(
7188 &self,
7189 spec: &PreviousVersionReadSpec,
7190 ) -> Result<PreviousVersionSnapshot, String> {
7191 let now_ms = self.clock_now_ms();
7192 crate::previous_version::previous_version_snapshot(
7193 &self.conn,
7194 self.previous_version_replica_path().as_deref(),
7195 self.previous_version.as_ref(),
7196 PreviousVersionLifecycle {
7197 lease_inactive: self.previous_version_lease_inactive(now_ms),
7198 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
7199 coverage_complete: self.previous_version_coverage_complete(),
7200 },
7201 now_ms,
7202 self.schema.version,
7203 spec,
7204 )
7205 }
7206
7207 #[must_use]
7209 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
7210 crate::previous_version::stored_previous_version_audit(&self.conn)
7211 }
7212
7213 pub fn previous_version_discard(
7217 &self,
7218 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
7219 self.drop_previous_version()
7220 }
7221
7222 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
7226 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
7227 present: false,
7228 discarded: false,
7229 };
7230 let Some(replica_path) = self.previous_version_replica_path() else {
7231 return absent;
7232 };
7233 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
7234 .unwrap_or(absent)
7235 }
7236
7237 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
7239 let Some(lease) = &self.lease_state else {
7240 return false;
7241 };
7242 if lease.error_code.is_some() {
7243 return true;
7244 }
7245 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
7246 }
7247
7248 fn previous_version_coverage_complete(&self) -> bool {
7252 let active = self
7253 .subs
7254 .iter()
7255 .filter(|sub| sub.state == SubState::Active)
7256 .collect::<Vec<_>>();
7257 if active.is_empty() {
7258 return false;
7259 }
7260 active
7261 .iter()
7262 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
7263 }
7264
7265 fn previous_version_lifetime_check(&self) {
7269 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
7270 return;
7271 };
7272 let now_ms = self.clock_now_ms();
7273 if self.previous_version_lease_inactive(now_ms) {
7274 let _ = self.drop_previous_version();
7275 return;
7276 }
7277 let Some(replica_path) = self.previous_version_replica_path() else {
7278 return;
7279 };
7280 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
7281 else {
7282 return;
7283 };
7284 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
7285 let _ = self.drop_previous_version();
7286 return;
7287 }
7288 if now_ms - record.created_at_ms > config.max_age_ms {
7289 let _ = self.drop_previous_version();
7290 return;
7291 }
7292 if self.previous_version_coverage_complete() {
7293 let _ = self.drop_previous_version();
7294 }
7295 }
7296
7297 pub fn diagnostics_snapshot(
7298 &self,
7299 request: &ClientDiagnosticsRequest,
7300 ) -> Result<ClientDiagnosticsSnapshot, String> {
7301 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
7302 return Err(format!(
7303 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
7304 ));
7305 }
7306 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
7307 for sub in &self.subs {
7308 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
7309 let complete =
7310 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
7311 let state = match sub.state {
7312 SubState::Revoked => "revoked",
7313 SubState::Failed => "failed",
7314 SubState::Active if reset => "reset",
7315 SubState::Active if complete => "complete",
7316 SubState::Active => "bootstrapping",
7317 };
7318 subscriptions.insert(
7319 sub.id.clone(),
7320 DiagnosticSubscription {
7321 id: sub.id.clone(),
7322 table: sub.table.clone(),
7323 state: state.to_owned(),
7324 complete,
7325 cursor: Some(sub.cursor),
7326 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
7327 },
7328 );
7329 }
7330 for expected in &request.expected_subscriptions {
7331 if expected.id.is_empty() || expected.table.is_empty() {
7332 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
7333 }
7334 if subscriptions
7335 .get(&expected.id)
7336 .is_some_and(|registered| registered.table != expected.table)
7337 {
7338 subscriptions.insert(
7339 expected.id.clone(),
7340 DiagnosticSubscription {
7341 id: expected.id.clone(),
7342 table: expected.table.clone(),
7343 state: "failed".to_owned(),
7344 complete: false,
7345 cursor: None,
7346 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
7347 },
7348 );
7349 } else {
7350 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
7351 DiagnosticSubscription {
7352 id: expected.id.clone(),
7353 table: expected.table.clone(),
7354 state: "unregistered".to_owned(),
7355 complete: false,
7356 cursor: None,
7357 reason_code: None,
7358 }
7359 });
7360 }
7361 }
7362 let mut ordered_subscriptions = Vec::new();
7363 let mut included = BTreeSet::new();
7364 for expected in &request.expected_subscriptions {
7365 if included.insert(expected.id.clone()) {
7366 if let Some(subscription) = subscriptions.get(&expected.id) {
7367 ordered_subscriptions.push(subscription.clone());
7368 }
7369 }
7370 }
7371 for (id, subscription) in &subscriptions {
7372 if included.insert(id.clone()) {
7373 ordered_subscriptions.push(subscription.clone());
7374 }
7375 }
7376 let subscriptions_truncated =
7377 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
7378 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
7379 let captured_at_ms = self.clock_now_ms();
7380 let lease = if let Some(error_code) = self
7381 .lease_state
7382 .as_ref()
7383 .and_then(|state| state.error_code.clone())
7384 {
7385 ClientDiagnosticsLease {
7386 state: "stopped".to_owned(),
7387 expires_at_ms: self
7388 .lease_state
7389 .as_ref()
7390 .and_then(|state| state.expires_at_ms),
7391 error_code: Some(Self::diagnostic_code(&error_code)),
7392 }
7393 } else if let Some(expires_at_ms) = self
7394 .lease_state
7395 .as_ref()
7396 .and_then(|state| state.expires_at_ms)
7397 {
7398 ClientDiagnosticsLease {
7399 state: if expires_at_ms <= captured_at_ms {
7400 "expired".to_owned()
7401 } else {
7402 "active".to_owned()
7403 },
7404 expires_at_ms: Some(expires_at_ms),
7405 error_code: None,
7406 }
7407 } else {
7408 ClientDiagnosticsLease {
7409 state: "none".to_owned(),
7410 expires_at_ms: None,
7411 error_code: None,
7412 }
7413 };
7414 let connectivity = match self.last_round.as_ref() {
7415 _ if !self.transport_enabled && self.active_round.is_none() => "offline",
7416 Some(round) if round.status == "succeeded" => "online",
7417 Some(round)
7418 if round.status == "failed"
7419 && round
7420 .error_code
7421 .as_deref()
7422 .is_some_and(Self::retryable_transport_code) =>
7423 {
7424 "offline"
7425 }
7426 _ => "unknown",
7427 };
7428 Ok(ClientDiagnosticsSnapshot {
7429 version: CLIENT_DIAGNOSTICS_VERSION,
7430 captured_at_ms,
7431 host: ClientDiagnosticsHost {
7432 kind: "direct".to_owned(),
7433 role: "single".to_owned(),
7434 connectivity: connectivity.to_owned(),
7435 realtime: self.realtime_state,
7436 realtime_policy: self.realtime_policy,
7437 realtime_reason_code: self.realtime_reason_code.clone(),
7438 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
7439 },
7440 security_lifecycle: self.security_lifecycle().to_owned(),
7441 schema: ClientDiagnosticsSchema {
7442 current_version: self.schema.version,
7443 upgrading: self.upgrading,
7444 required_version: self
7445 .schema_floor
7446 .as_ref()
7447 .and_then(|floor| floor.required_schema_version),
7448 latest_version: self
7449 .schema_floor
7450 .as_ref()
7451 .and_then(|floor| floor.latest_schema_version),
7452 },
7453 replica: ClientDiagnosticsReplica {
7454 local_revision: self.local_revision().to_string(),
7455 sync_needed: self.sync_needed,
7456 pending_outbox: self.outbox.len(),
7457 },
7458 lease,
7459 subscriptions: ordered_subscriptions,
7460 subscriptions_truncated,
7461 last_round: self.last_round.clone(),
7462 last_change: self.last_change.clone(),
7463 storage: self.diagnostics_storage(),
7464 query_failures: self.query_failures.clone(),
7465 })
7466 }
7467
7468 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
7469 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
7470 let page_count: i64 = self
7471 .conn
7472 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
7473 let page_size: i64 = self
7474 .conn
7475 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
7476 let outbox_bytes: i64 = self
7478 .conn
7479 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
7480 .query_row([], |row| row.get(0))?;
7481 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
7482 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
7483 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
7484 let blob_bytes = if self.schema_has_blobs() {
7485 self.conn
7486 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
7487 .query_row([], |row| row.get(0))?
7488 } else {
7489 0
7490 };
7491 let pressure = self
7492 .limits
7493 .blob_cache_max_bytes
7494 .is_some_and(|limit| blob_bytes > limit);
7495 Ok(ClientDiagnosticsStorage {
7496 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
7497 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
7498 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
7499 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
7500 retained_outcome_entries: Some(outcome_entries.max(0)),
7501 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
7502 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
7503 })
7504 };
7505 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
7506 status: "unreadable".to_owned(),
7507 database_bytes_approx: None,
7508 pending_outbox_bytes_approx: None,
7509 retained_outcome_bytes_approx: None,
7510 retained_outcome_entries: None,
7511 blob_cache_bytes_approx: None,
7512 pressure_reason_code: None,
7513 })
7514 }
7515
7516 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
7517 self.change_queue.drain(..).collect()
7518 }
7519
7520 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
7521 let intents = self.sync_intent_queue.drain(..).collect();
7522 if self.transport_enabled {
7523 intents
7524 } else {
7525 vec![SyncIntent::None]
7526 }
7527 }
7528
7529 fn schedule_background_retry(&mut self) -> u64 {
7530 let delay_ms = self.retry_delay_ms;
7531 self.sync_intent_queue
7532 .push_back(SyncIntent::Background { delay_ms });
7533 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
7534 self.round_retry_delay_ms = Some(delay_ms);
7535 delay_ms
7536 }
7537
7538 fn reset_background_retry(&mut self) {
7539 self.retry_delay_ms = 250;
7540 }
7541
7542 fn retryable_transport_code(code: &str) -> bool {
7543 code == "transport.failed"
7544 || code == "transport.unavailable"
7545 || code == "sync.transport_failed"
7546 }
7547
7548 fn retryable_failure_code(code: &str) -> bool {
7553 Self::retryable_transport_code(code)
7554 || matches!(
7555 code,
7556 "sync.auth_required"
7557 | "sync.auth_lease_required"
7558 | "sync.auth_lease_revoked"
7559 | "sync.internal_error"
7560 | "sync.idempotency_cache_miss"
7561 | "sync.schema_not_ready"
7562 | "sync.segment_expired"
7563 | "sync.rate_limited"
7564 | "sync.websocket_connection_limit"
7565 )
7566 }
7567
7568 fn diagnostic_code(code: &str) -> String {
7569 let valid = !code.is_empty()
7570 && code.len() <= 96
7571 && code.contains('.')
7572 && code
7573 .bytes()
7574 .next()
7575 .is_some_and(|byte| byte.is_ascii_lowercase())
7576 && code.bytes().all(|byte| {
7577 byte.is_ascii_lowercase()
7578 || byte.is_ascii_digit()
7579 || matches!(byte, b'.' | b'_' | b'-')
7580 });
7581 if valid {
7582 code.to_owned()
7583 } else {
7584 "client.unknown_failure".to_owned()
7585 }
7586 }
7587
7588 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
7589 if self.sync_needed != value {
7590 if self.begin_observation("syncular_status").is_ok() {
7591 self.sync_needed = value;
7592 let batch = ChangeAccumulator {
7593 status: true,
7594 ..ChangeAccumulator::default()
7595 };
7596 if self.finish_observation("syncular_status", batch).is_err() {
7597 self.rollback_observation("syncular_status");
7598 }
7599 } else {
7600 self.sync_needed = value;
7601 }
7602 }
7603 if value && interactive {
7604 self.sync_intent_queue.push_back(SyncIntent::Interactive);
7605 }
7606 }
7607
7608 fn sqlite_failure(
7609 storage_failure: &RefCell<Option<QueryReadFailure>>,
7610 error: rusqlite::Error,
7611 ) -> String {
7612 let message = error.to_string();
7613 let failure = QueryReadFailure::from(error);
7614 let code = failure.code;
7615 if code.is_some() && storage_failure.borrow().is_none() {
7616 *storage_failure.borrow_mut() = Some(failure);
7617 }
7618 match code {
7619 Some(code) => format!("{code}: {message}"),
7620 None => message,
7621 }
7622 }
7623
7624 fn begin_observation(&self, name: &str) -> Result<(), String> {
7625 self.conn
7626 .execute_batch(&format!("SAVEPOINT {name}"))
7627 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7628 }
7629
7630 fn rollback_observation(&self, name: &str) {
7631 if let Err(error) = self
7632 .conn
7633 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
7634 {
7635 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
7636 if failure.rollback_failure.is_none() {
7637 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
7638 }
7639 }
7640 }
7641 }
7642
7643 fn finish_observation(
7644 &mut self,
7645 name: &str,
7646 mut batch: ChangeAccumulator,
7647 ) -> Result<(), String> {
7648 #[cfg(feature = "bench-internals")]
7649 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
7650 if self.failed_commits.iter().any(|(commit, _)| {
7651 commit
7652 .ops
7653 .iter()
7654 .any(|operation| batch.tables.contains_key(&operation.table))
7655 }) {
7656 batch.outcomes = true;
7657 }
7658 if !batch.touched() {
7659 self.conn
7660 .execute_batch(&format!("RELEASE {name}"))
7661 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7662 return Ok(());
7663 }
7664 let revision = self
7665 .local_revision()
7666 .checked_add(1)
7667 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
7668 self.conn
7669 .execute(
7670 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
7671 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
7672 )
7673 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7674 let status = batch.status.then(|| self.status_snapshot());
7675 let event = ClientChangeBatch {
7676 revision: revision.to_string(),
7677 tables: batch
7678 .tables
7679 .into_iter()
7680 .map(|(table, scope_keys)| TableChange {
7681 table,
7682 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
7683 })
7684 .collect(),
7685 windows: batch
7686 .windows
7687 .into_iter()
7688 .map(|((base_key, table), units)| WindowChange {
7689 base_key,
7690 table,
7691 units: units.into_iter().collect(),
7692 })
7693 .collect(),
7694 status,
7695 conflicts_changed: batch.conflicts,
7696 rejections_changed: batch.rejections,
7697 outcomes_changed: batch.outcomes,
7698 };
7699 self.conn
7700 .execute_batch(&format!("RELEASE {name}"))
7701 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7702 let mut diagnostic_tables = event
7703 .tables
7704 .iter()
7705 .map(|entry| entry.table.clone())
7706 .collect::<BTreeSet<_>>()
7707 .into_iter()
7708 .collect::<Vec<_>>();
7709 let mut diagnostic_windows = event
7710 .windows
7711 .iter()
7712 .map(|entry| entry.table.clone())
7713 .collect::<BTreeSet<_>>()
7714 .into_iter()
7715 .collect::<Vec<_>>();
7716 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
7717 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
7718 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
7719 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
7720 self.last_change = Some(DiagnosticLastChange {
7721 revision: event.revision.clone(),
7722 recorded_at_ms: self.clock_now_ms(),
7723 tables: diagnostic_tables,
7724 windows: diagnostic_windows,
7725 domains_truncated,
7726 status_changed: event.status.is_some(),
7727 conflicts_changed: event.conflicts_changed,
7728 rejections_changed: event.rejections_changed,
7729 outcomes_changed: event.outcomes_changed,
7730 });
7731 self.change_queue.push_back(event);
7732 Ok(())
7733 }
7734
7735 fn record_scope_map(
7736 &self,
7737 batch: &mut ChangeAccumulator,
7738 table_name: &str,
7739 scopes: &[(String, Vec<String>)],
7740 ) {
7741 let Some(table) = self.schema.table(table_name) else {
7742 return;
7743 };
7744 for (variable, values) in scopes {
7745 let Some(scope) = table
7746 .scope_variables
7747 .iter()
7748 .find(|scope| &scope.variable == variable)
7749 else {
7750 continue;
7751 };
7752 for value in values {
7753 batch.scope(table_name, format!("{}:{value}", scope.prefix));
7754 }
7755 }
7756 }
7757
7758 fn record_row_scopes(
7760 &self,
7761 batch: &mut ChangeAccumulator,
7762 table_name: &str,
7763 row_id: &str,
7764 base: bool,
7765 ) -> bool {
7766 let Some(table) = self.schema.table(table_name) else {
7767 return false;
7768 };
7769 if table.scope_variables.is_empty() {
7770 return false;
7771 }
7772 let columns = table
7773 .scope_variables
7774 .iter()
7775 .map(|scope| quote_ident(&scope.column))
7776 .collect::<Vec<_>>()
7777 .join(", ");
7778 let full_table = if base {
7779 base_table(table_name)
7780 } else {
7781 visible_table(table_name)
7782 };
7783 let sql = format!(
7784 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
7785 row_id_predicate(table)
7786 );
7787 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
7788 return false;
7789 };
7790 let values = stmt.query_row(rusqlite::params![row_id], |row| {
7791 let mut values = Vec::with_capacity(table.scope_variables.len());
7792 for index in 0..table.scope_variables.len() {
7793 values.push(row.get::<_, Option<String>>(index)?);
7794 }
7795 Ok(values)
7796 });
7797 let Ok(values) = values else {
7798 return false;
7799 };
7800 let mut recorded = false;
7801 for (scope, value) in table.scope_variables.iter().zip(values) {
7802 if let Some(value) = value {
7803 batch.scope(table_name, format!("{}:{value}", scope.prefix));
7804 recorded = true;
7805 }
7806 }
7807 recorded
7808 }
7809
7810 fn record_commit_changes(
7811 &self,
7812 batch: &mut ChangeAccumulator,
7813 tables: &[String],
7814 changes: &[ssp2::model::Change],
7815 ) {
7816 #[cfg(feature = "bench-internals")]
7817 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
7818 for change in changes {
7819 let Some(table_name) = tables.get(change.table_index as usize) else {
7820 continue;
7821 };
7822 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
7823 if let Some(table) = self.schema.table(table_name) {
7824 for (variable, value) in &change.scopes {
7825 if let Some(scope) = table
7826 .scope_variables
7827 .iter()
7828 .find(|scope| &scope.variable == variable)
7829 {
7830 batch.scope(table_name, format!("{}:{value}", scope.prefix));
7831 precise = true;
7832 }
7833 }
7834 }
7835 if !precise {
7836 batch.table(table_name);
7837 }
7838 }
7839 }
7840
7841 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
7842 if effective.is_empty() {
7843 return false;
7844 }
7845 let Some(table) = self.schema.table(table_name) else {
7846 return false;
7847 };
7848 let mut clauses = Vec::new();
7849 let mut params = Vec::new();
7850 for (variable, values) in effective {
7851 let Some(column) = table.scope_column(variable) else {
7852 return false;
7853 };
7854 if values.is_empty() {
7855 return false;
7856 }
7857 let placeholders = values
7858 .iter()
7859 .map(|value| {
7860 params.push(SqlValue::Text(value.clone()));
7861 "?"
7862 })
7863 .collect::<Vec<_>>()
7864 .join(", ");
7865 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
7866 }
7867 let sql = format!(
7868 "SELECT 1 FROM {} WHERE {} LIMIT 1",
7869 base_table(table_name),
7870 clauses.join(" AND ")
7871 );
7872 self.conn
7873 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
7874 .is_ok()
7875 }
7876
7877 pub fn upgrading(&self) -> bool {
7879 self.upgrading
7880 }
7881
7882 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
7888 let new_schema = parse_schema_json(schema_json)?;
7889 validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
7890 let marker: Option<i32> = self
7891 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7892 .and_then(|v| v.parse().ok());
7893 self.schema = new_schema;
7894 self.create_bookkeeping_tables()?;
7895 if marker != Some(self.schema.version) {
7896 self.run_schema_reset()?;
7897 }
7898 self.prune_unknown_subscriptions(false)?;
7899 self.save_subscription_scope_schema();
7900 self.enqueue_startup_sync_if_needed()?;
7904 Ok(())
7905 }
7906
7907 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
7908 let startup_work = !self.stopped
7909 && (!self.outbox.is_empty()
7910 || self.has_acknowledged_rows()
7911 || !self.load_pending_evictions()?.is_empty()
7912 || self.subs.iter().any(|sub| sub.state == SubState::Active));
7913 if startup_work {
7914 self.sync_needed = true;
7915 self.sync_intent_queue.push_back(SyncIntent::Interactive);
7916 }
7917 Ok(())
7918 }
7919
7920 fn clear_satisfied_persisted_schema_floor(&mut self) {
7927 let satisfied = self
7928 .schema_floor
7929 .as_ref()
7930 .and_then(|floor| floor.required_schema_version)
7931 .is_some_and(|required| self.schema.version >= required);
7932 if !satisfied {
7933 return;
7934 }
7935 self.schema_floor = None;
7936 self.stopped = false;
7937 self.delete_meta(SCHEMA_FLOOR_KEY);
7938 }
7939
7940 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
7945 let Some(replica_path) = self.previous_version_replica_path() else {
7946 return Ok(());
7947 };
7948 let max_age_ms = self
7949 .previous_version
7950 .filter(|config| config.enabled)
7951 .map(|config| config.max_age_ms);
7952 reconcile_previous_version_at_boot(
7953 &self.conn,
7954 &replica_path,
7955 self.schema.version,
7956 max_age_ms,
7957 self.clock_now_ms(),
7958 )
7959 }
7960
7961 fn previous_version_replica_path(&self) -> Option<String> {
7965 let path = self.conn.path()?;
7966 if path.is_empty() || path.starts_with("file:") {
7967 return None;
7968 }
7969 Some(path.to_owned())
7970 }
7971
7972 fn run_schema_reset(&mut self) -> Result<(), String> {
7978 self.begin_observation("syncular_schema_reset")?;
7979 let prior_subs = self.subs.clone();
7980 let mut batch = ChangeAccumulator::default();
7981 let result = self.run_schema_reset_observed(&mut batch, true, true);
7982 if let Err(error) = result {
7983 self.rollback_observation("syncular_schema_reset");
7984 self.subs = prior_subs;
7985 return Err(error);
7986 }
7987 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
7988 self.rollback_observation("syncular_schema_reset");
7989 self.subs = prior_subs;
7990 return Err(error);
7991 }
7992 Ok(())
7993 }
7994
7995 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
7996 if self.get_meta(LOG_EPOCH_KEY).is_none() {
7997 self.begin_observation("syncular_epoch_acquisition")?;
7998 let previous_sync_needed = self.sync_needed;
7999 if let Err(error) = self.conn.execute(
8000 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
8001 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
8002 ) {
8003 self.rollback_observation("syncular_epoch_acquisition");
8004 return Err(Self::sqlite_failure(&self.storage_failure, error));
8005 }
8006 self.sync_needed = true;
8007 let batch = ChangeAccumulator {
8008 status: true,
8009 ..ChangeAccumulator::default()
8010 };
8011 if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
8012 self.rollback_observation("syncular_epoch_acquisition");
8013 self.sync_needed = previous_sync_needed;
8014 return Err(error);
8015 }
8016 self.sync_intent_queue.push_back(SyncIntent::Interactive);
8017 return Ok(Vec::new());
8018 }
8019 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
8020 self.begin_observation("syncular_log_epoch_reset")?;
8021 let prior_subs = self.subs.clone();
8022 let mut batch = ChangeAccumulator::default();
8023 let result = self.run_schema_reset_observed(&mut batch, false, false);
8024 if let Err(error) = result {
8025 self.rollback_observation("syncular_log_epoch_reset");
8026 self.subs = prior_subs;
8027 return Err(error);
8028 }
8029 self.set_meta(LOG_EPOCH_KEY, log_epoch);
8030 self.sync_needed = true;
8031 batch.status = true;
8032 if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
8033 self.rollback_observation("syncular_log_epoch_reset");
8034 self.subs = prior_subs;
8035 return Err(error);
8036 }
8037 self.sync_intent_queue.push_back(SyncIntent::Interactive);
8038 Ok(resets)
8039 }
8040
8041 fn run_schema_reset_observed(
8042 &mut self,
8043 batch: &mut ChangeAccumulator,
8044 drop_incompatible: bool,
8045 capture: bool,
8046 ) -> Result<(), String> {
8047 self.conn
8048 .execute_batch(
8049 "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
8050 )
8051 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8052 self.cancel_sync_round();
8053 self.upgrading = true;
8054 batch.status = true;
8055 if self.previous_version.is_some_and(|config| config.enabled) {
8061 let previous_version = self
8062 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
8063 .and_then(|value| value.parse::<i32>().ok())
8064 .unwrap_or(self.schema.version);
8065 let pending = self
8066 .outbox
8067 .iter()
8068 .map(|commit| PendingCommitAudit {
8069 commit_id: &commit.client_commit_id,
8070 operations: commit_audit_operations(commit),
8071 })
8072 .collect::<Vec<_>>();
8073 crate::previous_version::write_previous_version_audit(
8074 &self.conn,
8075 &crate::previous_version::build_previous_version_audit(
8076 &self.schema,
8077 &pending,
8078 previous_version,
8079 self.schema.version,
8080 self.clock_now_ms(),
8081 ),
8082 );
8083 }
8084 if let Some(replica_path) = self.previous_version_replica_path() {
8089 sweep_previous_version_container(&self.conn, &replica_path)?;
8090 if capture {
8091 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
8092 let previous_version = self
8093 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
8094 .and_then(|value| value.parse::<i32>().ok())
8095 .unwrap_or(self.schema.version);
8096 capture_previous_version_from_replica(
8097 &self.conn,
8098 &replica_path,
8099 previous_version,
8100 self.schema.version,
8101 &config,
8102 self.clock_now_ms(),
8103 )?;
8104 }
8105 }
8106 }
8107 for table in &self.schema.tables {
8108 batch.table(&table.name);
8109 }
8110 for (base_key, unit, table) in self.load_registered_window_units() {
8111 batch.window(&base_key, &table, &unit);
8112 }
8113 if capture {
8114 self.prune_unknown_subscriptions(true)?;
8115 }
8116 self.insert_sql.borrow_mut().clear();
8118 self.overlay_dirty.set(true);
8119 let virtual_tables: Vec<String> = {
8127 let mut stmt = self
8128 .conn
8129 .prepare(
8130 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
8131 )
8132 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8133 let rows = stmt
8134 .query_map([], |row| row.get::<_, String>(0))
8135 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8136 rows.filter_map(Result::ok)
8137 .filter(|name| is_synced_table_name(name))
8138 .collect()
8139 };
8140 for name in virtual_tables {
8141 self.conn
8142 .execute_batch(&format!(
8143 "DROP TABLE IF EXISTS {}",
8144 quote_ident(&format!("_syncular_fts_{name}"))
8145 ))
8146 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8147 self.conn
8148 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
8149 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8150 }
8151 let existing: Vec<String> = {
8152 let mut stmt = self
8153 .conn
8154 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
8155 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8156 let rows = stmt
8157 .query_map([], |row| row.get::<_, String>(0))
8158 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8159 rows.filter_map(Result::ok)
8160 .filter(|name| is_synced_table_name(name))
8161 .collect()
8162 };
8163 for name in &existing {
8164 if let Some(table) = name.strip_prefix("_syncular_base_") {
8165 batch.table(table);
8166 } else if !name.starts_with("_syncular_") {
8167 batch.table(name);
8168 }
8169 }
8170 for name in existing {
8171 self.conn
8172 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
8173 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8174 }
8175 self.create_synced_tables()?;
8177 for sub in &mut self.subs {
8179 sub.cursor = -1;
8180 sub.bootstrap_state = None;
8181 sub.effective = None;
8182 sub.state = SubState::Active;
8183 sub.reason_code = None;
8184 sub.synced_once = false;
8185 }
8186 let subs = self.subs.clone();
8187 for sub in &subs {
8188 self.persist_sub(sub)?;
8189 }
8190 self.stopped = false;
8192 self.schema_floor = None;
8193 self.delete_meta(SCHEMA_FLOOR_KEY);
8194 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
8196 set_local_schema_descriptor(&self.conn, &self.schema);
8199 self.save_subscription_scope_schema();
8200 if drop_incompatible && self.drop_incompatible_outbox()? {
8204 batch.rejections = true;
8205 batch.status = true;
8206 batch.outcomes = true;
8207 }
8208 self.rebuild_overlay();
8210 Ok(())
8211 }
8212
8213 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
8217 let incompatible = self
8218 .outbox
8219 .iter()
8220 .filter(|commit| {
8221 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
8222 })
8223 .cloned()
8224 .collect::<Vec<_>>();
8225 if incompatible.is_empty() {
8226 return Ok(false);
8227 }
8228 let mut rejections = Vec::new();
8229 for commit in &incompatible {
8230 let results = commit
8231 .ops
8232 .iter()
8233 .enumerate()
8234 .map(|(op_index, operation)| {
8235 let rejection = RejectionRecord {
8236 client_commit_id: commit.client_commit_id.clone(),
8237 op_index: op_index as i32,
8238 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
8239 message: "the persisted commit cannot encode under the current schema"
8240 .to_owned(),
8241 retryable: false,
8242 details: None,
8243 operation: Some(CommitOperation::from(operation)),
8244 };
8245 rejections.push(rejection.clone());
8246 CommitOperationOutcome::Error { rejection }
8247 })
8248 .collect::<Vec<_>>();
8249 self.persist_commit_outcome(
8250 &commit.client_commit_id,
8251 CommitOutcomeStatus::Rejected,
8252 &results,
8253 Some(&commit.ops),
8254 )?;
8255 self.delete_outbox_persisted(&commit.client_commit_id)?;
8256 }
8257 self.prune_commit_outcomes()?;
8258 let incompatible_ids = incompatible
8259 .iter()
8260 .map(|commit| commit.client_commit_id.as_str())
8261 .collect::<BTreeSet<_>>();
8262 self.outbox
8263 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
8264 self.rejections.extend(rejections);
8265 Ok(true)
8266 }
8267
8268 fn create_synced_tables(&self) -> Result<(), String> {
8271 for table in &self.schema.tables {
8272 for (full, index_prefix) in [
8276 (base_table(&table.name), "_syncular_base_"),
8277 (visible_table(&table.name), ""),
8278 ] {
8279 let mut cols: Vec<String> =
8280 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
8281 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
8282 let sql = format!(
8283 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
8284 cols.join(", "),
8285 quote_ident(&table.primary_key)
8286 );
8287 self.conn
8288 .execute(&sql, [])
8289 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8290 for index in &table.indexes {
8294 let unique = if index.unique { "UNIQUE " } else { "" };
8295 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
8296 let cols_sql = index
8297 .columns
8298 .iter()
8299 .map(|c| quote_ident(c))
8300 .collect::<Vec<_>>()
8301 .join(", ");
8302 let index_sql = format!(
8303 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
8304 );
8305 self.conn
8306 .execute(&index_sql, [])
8307 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8308 }
8309 }
8310 }
8311 for table in &self.schema.tables {
8314 for index in &table.fts_indexes {
8315 self.create_fts_projection(table, index)?;
8316 }
8317 }
8318 Ok(())
8319 }
8320
8321 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
8322 let count: i64 = self
8323 .conn
8324 .query_row(
8325 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
8326 rusqlite::params![index.name],
8327 |row| row.get(0),
8328 )
8329 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8330 Ok(count > 0)
8331 }
8332
8333 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
8334 for suffix in ["ai", "ad", "au"] {
8335 self.conn
8336 .execute(
8337 &format!(
8338 "DROP TRIGGER IF EXISTS {}",
8339 quote_ident(&format!("{}_{suffix}", index.name))
8340 ),
8341 [],
8342 )
8343 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8344 }
8345 Ok(())
8346 }
8347
8348 fn create_fts_triggers(
8349 &self,
8350 table: &TableSchema,
8351 index: &FtsIndexSchema,
8352 ) -> Result<(), String> {
8353 let fts = quote_ident(&index.name);
8354 let source = visible_table(&table.name);
8355 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
8356 let pk = quote_ident(&table.primary_key);
8357 let projection_columns = std::iter::once(source_id.clone())
8358 .chain(index.columns.iter().map(|column| quote_ident(column)))
8359 .collect::<Vec<_>>()
8360 .join(", ");
8361 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
8362 .chain(
8363 index
8364 .columns
8365 .iter()
8366 .map(|column| format!("new.{}", quote_ident(column))),
8367 )
8368 .collect::<Vec<_>>()
8369 .join(", ");
8370 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
8371 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)");
8372 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)");
8373 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})");
8374 let bi = quote_ident(&format!("{}_bi", index.name));
8375 let ai = quote_ident(&format!("{}_ai", index.name));
8376 let ad = quote_ident(&format!("{}_ad", index.name));
8377 let au = quote_ident(&format!("{}_au", index.name));
8378 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
8379 let sql = format!(
8380 "DROP TRIGGER IF EXISTS {bi};
8381 DROP TRIGGER IF EXISTS {ai};
8382 DROP TRIGGER IF EXISTS {ad};
8383 DROP TRIGGER IF EXISTS {au};
8384 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
8385 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
8386 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
8387 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
8388 );
8389 self.conn
8390 .execute_batch(&sql)
8391 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8392 }
8393
8394 fn rebuild_fts_projection(
8395 &self,
8396 table: &TableSchema,
8397 index: &FtsIndexSchema,
8398 ) -> Result<(), String> {
8399 let fts = quote_ident(&index.name);
8400 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
8401 let pk = quote_ident(&table.primary_key);
8402 let indexed_columns = index
8403 .columns
8404 .iter()
8405 .map(|column| quote_ident(column))
8406 .collect::<Vec<_>>();
8407 let projection_columns = std::iter::once(source_id.clone())
8408 .chain(indexed_columns.iter().cloned())
8409 .collect::<Vec<_>>()
8410 .join(", ");
8411 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
8412 let sql = format!(
8413 "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};",
8414 columns = indexed_columns.join(", "),
8415 source = visible_table(&table.name),
8416 );
8417 self.conn
8418 .execute_batch(&sql)
8419 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8420 }
8421
8422 fn create_fts_projection(
8423 &self,
8424 table: &TableSchema,
8425 index: &FtsIndexSchema,
8426 ) -> Result<(), String> {
8427 self.begin_observation("syncular_fts_schema")?;
8428 let result = (|| {
8429 let existed = self.fts_projection_exists(index)?;
8430 let tokenizer = index.tokenize.replace('\'', "''");
8431 let columns = index
8432 .columns
8433 .iter()
8434 .map(|column| quote_ident(column))
8435 .collect::<Vec<_>>()
8436 .join(", ");
8437 let sql = format!(
8438 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
8439 fts = quote_ident(&index.name),
8440 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
8441 );
8442 self.conn.execute(&sql, []).map_err(|error| {
8443 format!(
8444 "cannot create local FTS5 projection {:?}: {error}",
8445 index.name
8446 )
8447 })?;
8448 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
8449 let mapped: bool = self
8450 .conn
8451 .query_row(
8452 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
8453 [format!("_syncular_fts_{}", index.name)],
8454 |row| row.get(0),
8455 )
8456 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8457 self.conn.execute_batch(&format!("CREATE TABLE IF NOT EXISTS {mapping}(id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)")).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8458 if !mapped && existed {
8459 self.conn
8460 .execute_batch(&format!(
8461 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
8462 quote_ident(FTS_SOURCE_ID_COLUMN),
8463 quote_ident(&index.name)
8464 ))
8465 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8466 }
8467 self.create_fts_triggers(table, index)?;
8468 if !existed {
8469 self.rebuild_fts_projection(table, index)?;
8470 }
8471 Ok(())
8472 })();
8473 if let Err(error) = result {
8474 self.rollback_observation("syncular_fts_schema");
8475 return Err(error);
8476 }
8477 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
8478 self.rollback_observation("syncular_fts_schema");
8479 return Err(error.to_string());
8480 }
8481 Ok(())
8482 }
8483
8484 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
8487 let state = serde_json::json!({
8488 "requested": scope_map_to_json(&sub.requested),
8489 "params": sub.params,
8490 "cursor": sub.cursor,
8491 "bootstrapState": sub.bootstrap_state,
8492 "status": sub.state.name(),
8493 "reasonCode": sub.reason_code,
8494 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
8495 "syncedOnce": sub.synced_once,
8496 });
8497 self.conn.execute(
8498 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
8499 rusqlite::params![sub.id, sub.table, state.to_string()],
8500 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8501 }
8502
8503 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
8504 let ops: Vec<Value> = commit
8505 .ops
8506 .iter()
8507 .map(|op| {
8508 serde_json::json!({
8509 "op": if op.upsert { "upsert" } else { "delete" },
8510 "table": op.table,
8511 "rowId": op.row_id,
8512 "baseVersion": op.base_version,
8513 "values": op.values.clone().map(Value::Object),
8514 })
8515 })
8516 .collect();
8517 self.conn
8518 .execute(
8519 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
8520 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
8521 )
8522 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8523 for operation in &commit.ops {
8524 let Some(values) = &operation.values else {
8525 continue;
8526 };
8527 let Some(table) = self.schema.table(&operation.table) else {
8528 continue;
8529 };
8530 for column in table
8531 .columns
8532 .iter()
8533 .filter(|column| column.ty == ColumnType::BlobRef)
8534 {
8535 let Some(Value::String(raw)) = values.get(&column.name) else {
8536 continue;
8537 };
8538 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
8539 continue;
8540 };
8541 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
8542 continue;
8543 };
8544 self.conn
8545 .execute(
8546 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
8547 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
8548 rusqlite::params![commit.client_commit_id, blob_id],
8549 )
8550 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8551 }
8552 }
8553 Ok(())
8554 }
8555
8556 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
8557 if let Some(commit) = self
8558 .outbox
8559 .iter()
8560 .find(|commit| commit.client_commit_id == client_commit_id)
8561 {
8562 for operation in &commit.ops {
8563 self.overlay_dirty.table(&operation.table);
8564 }
8565 }
8566
8567 self.conn
8568 .execute(
8569 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
8570 rusqlite::params![client_commit_id],
8571 )
8572 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8573 self.conn
8574 .execute(
8575 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
8576 rusqlite::params![client_commit_id],
8577 )
8578 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8579 self.conn.execute("DELETE FROM _syncular_row_deliveries WHERE NOT EXISTS(SELECT 1 FROM _syncular_outbox,json_each(ops_json) WHERE json_extract(json_each.value,'$.table')=tbl AND json_extract(json_each.value,'$.rowId')=id)", [])
8580 .map(|_| ())
8581 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8582 }
8583
8584 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
8585 match status {
8586 CommitOutcomeStatus::Applied => "applied",
8587 CommitOutcomeStatus::Cached => "cached",
8588 CommitOutcomeStatus::Conflict => "conflict",
8589 CommitOutcomeStatus::Rejected => "rejected",
8590 }
8591 }
8592
8593 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
8594 match resolution {
8595 CommitOutcomeResolution::Active => "active",
8596 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
8597 CommitOutcomeResolution::Superseded => "superseded",
8598 CommitOutcomeResolution::Dismissed => "dismissed",
8599 }
8600 }
8601
8602 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
8603 match value {
8604 "applied" => Ok(CommitOutcomeStatus::Applied),
8605 "cached" => Ok(CommitOutcomeStatus::Cached),
8606 "conflict" => Ok(CommitOutcomeStatus::Conflict),
8607 "rejected" => Ok(CommitOutcomeStatus::Rejected),
8608 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
8609 }
8610 }
8611
8612 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
8613 match value {
8614 "active" => Ok(CommitOutcomeResolution::Active),
8615 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
8616 "superseded" => Ok(CommitOutcomeResolution::Superseded),
8617 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
8618 _ => Err(format!(
8619 "invalid persisted commit outcome resolution {value:?}"
8620 )),
8621 }
8622 }
8623
8624 fn persist_commit_outcome(
8625 &self,
8626 client_commit_id: &str,
8627 status: CommitOutcomeStatus,
8628 results: &[CommitOperationOutcome],
8629 operations: Option<&[OutboxOp]>,
8630 ) -> Result<(), String> {
8631 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
8632 let operations_json = operations
8633 .map(|items| {
8634 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
8635 })
8636 .transpose()
8637 .map_err(|error| error.to_string())?;
8638 self.conn
8639 .execute(
8640 "INSERT INTO _syncular_commit_outcomes (
8641 client_commit_id, status, recorded_at_ms, results_json,
8642 operations_json, resolution
8643 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
8644 rusqlite::params![
8645 client_commit_id,
8646 Self::outcome_status_name(status),
8647 self.clock_now_ms(),
8648 results_json,
8649 operations_json
8650 ],
8651 )
8652 .map(|_| ())
8653 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8654 }
8655
8656 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
8657 let StoredCommitOutcomeRow {
8658 sequence,
8659 client_commit_id,
8660 status,
8661 recorded_at_ms,
8662 results_json,
8663 operations_json,
8664 resolution,
8665 resolved_at_ms,
8666 replacement_client_commit_id,
8667 } = row;
8668 Ok(CommitOutcome {
8669 sequence,
8670 client_commit_id,
8671 status: Self::parse_outcome_status(&status)?,
8672 recorded_at_ms,
8673 results: serde_json::from_str(&results_json)
8674 .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
8675 operations: operations_json
8676 .map(|value| {
8677 serde_json::from_str(&value).map_err(|error| {
8678 format!("invalid persisted commit outcome operations: {error}")
8679 })
8680 })
8681 .transpose()?,
8682 retained_rows: None,
8683 resolution: Self::parse_outcome_resolution(&resolution)?,
8684 resolved_at_ms,
8685 replacement_client_commit_id,
8686 })
8687 }
8688
8689 fn unique_conflicts(
8691 &self,
8692 table: &TableSchema,
8693 values: &Map<String, Value>,
8694 base: bool,
8695 ) -> Result<Vec<RetainedUniqueConflict>, String> {
8696 let source = if base {
8697 base_table(&table.name)
8698 } else {
8699 visible_table(&table.name)
8700 };
8701 let mut conflicts = Vec::new();
8702 for index in table.indexes.iter().filter(|index| index.unique) {
8703 let mut params = vec![json_param_to_sql(
8704 values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
8705 )?];
8706 for name in &index.columns {
8707 let column = table
8708 .columns
8709 .iter()
8710 .find(|column| column.name == *name)
8711 .ok_or("sync.local_corrupt")?;
8712 let cell = json_to_column_value(column, values.get(name))?;
8713 params.push(owned_sql_value(RowParam::Cell(&cell))?);
8714 }
8715 let sql = format!(
8716 "SELECT * FROM {source} WHERE {} != ? AND {}",
8717 quote_ident(&table.primary_key),
8718 index
8719 .columns
8720 .iter()
8721 .map(|column| format!("{} = ?", quote_ident(column)))
8722 .collect::<Vec<_>>()
8723 .join(" AND ")
8724 );
8725 let mut statement = self
8726 .conn
8727 .prepare_cached(&sql)
8728 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8729 let records = statement
8730 .query_map(rusqlite::params_from_iter(params), |record| {
8731 let values = table
8732 .columns
8733 .iter()
8734 .enumerate()
8735 .map(|(index, column)| {
8736 let value = sql_ref_to_json(column, record.get_ref(index)?);
8737 json_to_column_value(column, Some(&value)).map_err(|message| {
8738 rusqlite::Error::ToSqlConversionFailure(message.into())
8739 })
8740 })
8741 .collect::<Result<Row, _>>()?;
8742 Ok((values, record.get::<_, i64>(table.columns.len())?))
8743 })
8744 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8745 for record in records {
8746 let (row, version) =
8747 record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8748 let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
8749 conflicts.push(RetainedUniqueConflict {
8750 index: index.name.clone(),
8751 columns: index.columns.clone(),
8752 row_id,
8753 server_row: table
8754 .columns
8755 .iter()
8756 .zip(&row)
8757 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
8758 .collect(),
8759 server_version: version,
8760 });
8761 }
8762 }
8763 Ok(conflicts)
8764 }
8765
8766 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
8767 if let Some((commit, initial)) = self
8768 .failed_commits
8769 .iter()
8770 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
8771 {
8772 let mut rows = Vec::new();
8773 for operation in &commit.ops {
8774 let table = self
8775 .schema
8776 .table(&operation.table)
8777 .ok_or_else(|| "sync.unknown_table".to_owned())?;
8778 let key =
8779 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8780 .map_err(|_| "sync.local_corrupt".to_owned())?;
8781 let base = self.stored_row(table, &operation.row_id, true);
8782 rows.push(RetainedCommitRow {
8783 table: operation.table.clone(),
8784 row_id: operation.row_id.clone(),
8785 unique_conflicts: if operation.upsert {
8786 self.unique_conflicts(
8787 table,
8788 initial
8789 .get(&key)
8790 .and_then(Value::as_object)
8791 .or(operation.values.as_ref())
8792 .ok_or("sync.local_corrupt")?,
8793 true,
8794 )?
8795 } else {
8796 Vec::new()
8797 },
8798 local_row: if operation.upsert {
8799 initial
8800 .get(&key)
8801 .and_then(Value::as_object)
8802 .cloned()
8803 .or_else(|| operation.values.clone())
8804 } else {
8805 None
8806 },
8807 server_row: base.as_ref().map(|(values, _)| {
8808 table
8809 .columns
8810 .iter()
8811 .zip(values)
8812 .map(|(column, value)| {
8813 (column.name.clone(), column_value_to_json(value))
8814 })
8815 .collect()
8816 }),
8817 server_version: base.map(|(_, version)| version),
8818 });
8819 }
8820 outcome.retained_rows = Some(rows);
8821 }
8822 Ok(outcome)
8823 }
8824
8825 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
8826 let row = self
8827 .conn
8828 .query_row(
8829 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8830 resolution, resolved_at_ms, replacement_client_commit_id
8831 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
8832 rusqlite::params![client_commit_id],
8833 |row| {
8834 Ok(StoredCommitOutcomeRow {
8835 sequence: row.get(0)?,
8836 client_commit_id: row.get(1)?,
8837 status: row.get(2)?,
8838 recorded_at_ms: row.get(3)?,
8839 results_json: row.get(4)?,
8840 operations_json: row.get(5)?,
8841 resolution: row.get(6)?,
8842 resolved_at_ms: row.get(7)?,
8843 replacement_client_commit_id: row.get(8)?,
8844 })
8845 },
8846 )
8847 .optional()
8848 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8849 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
8850 .transpose()
8851 }
8852
8853 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
8854 if query.limit == Some(0) {
8855 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
8856 }
8857 let mut sql = String::from(
8858 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8859 resolution, resolved_at_ms, replacement_client_commit_id
8860 FROM _syncular_commit_outcomes",
8861 );
8862 if query.active_only {
8863 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
8864 }
8865 sql.push_str(" ORDER BY seq DESC");
8866 if let Some(limit) = query.limit {
8867 sql.push_str(&format!(" LIMIT {limit}"));
8868 }
8869 let mut stmt = self
8870 .conn
8871 .prepare(&sql)
8872 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8873 let rows = stmt
8874 .query_map([], |row| {
8875 Ok(StoredCommitOutcomeRow {
8876 sequence: row.get(0)?,
8877 client_commit_id: row.get(1)?,
8878 status: row.get(2)?,
8879 recorded_at_ms: row.get(3)?,
8880 results_json: row.get(4)?,
8881 operations_json: row.get(5)?,
8882 resolution: row.get(6)?,
8883 resolved_at_ms: row.get(7)?,
8884 replacement_client_commit_id: row.get(8)?,
8885 })
8886 })
8887 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8888 let mut outcomes = Vec::new();
8889 for row in rows {
8890 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
8891 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
8892 )?)?);
8893 }
8894 Ok(outcomes)
8895 }
8896
8897 fn prune_commit_outcomes(&self) -> Result<(), String> {
8898 #[cfg(test)]
8899 self.outcome_prune_count
8900 .set(self.outcome_prune_count.get() + 1);
8901 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
8902 let count = self
8903 .conn
8904 .query_row(
8905 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
8906 [],
8907 |row| row.get::<_, i64>(0),
8908 )
8909 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8910 as usize;
8911 let excess = count.saturating_sub(max_entries);
8912 if excess == 0 {
8913 return Ok(());
8914 }
8915 let mut stmt = self
8916 .conn
8917 .prepare(
8918 "SELECT seq FROM _syncular_commit_outcomes
8919 WHERE status IN ('applied', 'cached') OR resolution != 'active'
8920 ORDER BY seq ASC LIMIT ?1",
8921 )
8922 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8923 let rows = stmt
8924 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
8925 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8926 let sequences = rows
8927 .collect::<Result<Vec<_>, _>>()
8928 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8929 drop(stmt);
8930 for sequence in sequences {
8931 self.conn
8932 .execute(
8933 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
8934 rusqlite::params![sequence],
8935 )
8936 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8937 }
8938 Ok(())
8939 }
8940
8941 pub fn subscribe(
8944 &mut self,
8945 id: String,
8946 table: String,
8947 scopes: Vec<(String, Vec<String>)>,
8948 params: Option<String>,
8949 ) -> Result<(), String> {
8950 if self.schema.table(&table).is_none() {
8951 return Err(format!("unknown table {table:?}"));
8952 }
8953 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
8954 let same_intent = existing.table == table
8955 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
8956 && existing.params == params;
8957 if same_intent {
8958 return Ok(());
8959 }
8960 return Err(
8961 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
8962 .to_owned(),
8963 );
8964 }
8965 let sub = Subscription {
8966 id: id.clone(),
8967 table,
8968 requested: scopes,
8969 params,
8970 cursor: -1,
8971 bootstrap_state: None,
8972 state: SubState::Active,
8973 reason_code: None,
8974 effective: None,
8975 synced_once: false,
8976 };
8977 self.persist_sub(&sub)?;
8978 self.subs.push(sub);
8979 Ok(())
8980 }
8981
8982 pub fn unsubscribe(&mut self, id: &str) {
8983 if self.subs.iter().any(|s| s.id == id) {
8984 self.cancel_sync_round();
8985 }
8986 self.subs.retain(|s| s.id != id);
8987 let _ = self.conn.execute(
8988 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8989 rusqlite::params![id],
8990 );
8991 }
8992
8993 pub fn set_window(
9001 &mut self,
9002 base: &WindowBase,
9003 units: &[String],
9004 ) -> Result<CommandEffects, String> {
9005 let table = self
9006 .schema
9007 .table(&base.table)
9008 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
9009 if table.scope_column(&base.variable).is_none() {
9010 return Err(format!(
9011 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
9012 base.table, base.variable
9013 ));
9014 }
9015 let base_key = window_base_key(base);
9016 let wanted: std::collections::HashSet<&String> = units.iter().collect();
9017 let live = self.load_window_units(&base_key);
9018 self.begin_observation("syncular_window")?;
9019 let mut batch = ChangeAccumulator::default();
9020 let mut changed = false;
9021
9022 let prior_subs = self.subs.clone();
9023 let widened = (|| {
9024 for unit in units {
9026 if live.iter().any(|(u, _)| u == unit) {
9027 continue;
9028 }
9029 let sub_id = derive_sub_id(base, unit);
9030 self.delete_pending_evict(&sub_id)?;
9031 self.insert_window_unit(&base_key, unit, &sub_id)?;
9032 self.subscribe(
9033 sub_id,
9034 base.table.clone(),
9035 unit_scopes(base, unit),
9036 base.params.clone(),
9037 )?;
9038 batch.window(&base_key, &base.table, unit);
9039 changed = true;
9040 }
9041
9042 self.finish_observation("syncular_window", batch)
9043 })();
9044 if let Err(error) = widened {
9045 self.rollback_observation("syncular_window");
9046 self.subs = prior_subs;
9047 return Err(error);
9048 }
9049 for (unit, sub_id) in live {
9050 if wanted.contains(&unit) {
9051 continue;
9052 }
9053 self.rebuild_overlay_if_dirty();
9054 let prior_subs = self.subs.clone();
9055 self.begin_observation("syncular_window")?;
9056 let effective = self
9057 .subs
9058 .iter()
9059 .find(|sub| sub.id == sub_id)
9060 .and_then(|sub| sub.effective.clone())
9061 .unwrap_or_else(|| unit_scopes(base, &unit));
9062 let mut batch = ChangeAccumulator::default();
9063 self.record_scope_map(&mut batch, &base.table, &effective);
9064 batch.window(&base_key, &base.table, &unit);
9065 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
9066 let remaining = match result {
9067 Ok(remaining) => remaining,
9068 Err(error) => {
9069 self.rollback_observation("syncular_window");
9070 self.subs = prior_subs;
9071 return Err(error);
9072 }
9073 };
9074 if let Err(error) = self.finish_observation("syncular_window", batch) {
9075 self.rollback_observation("syncular_window");
9076 self.subs = prior_subs;
9077 return Err(error);
9078 }
9079 if remaining {
9080 self.drain_pending_evictions()?;
9081 }
9082 changed = true;
9083 }
9084 Ok(if changed {
9085 CommandEffects::interactive()
9086 } else {
9087 CommandEffects::none()
9088 })
9089 }
9090
9091 pub fn window_state(&self, base: &WindowBase) -> WindowState {
9096 let mut units = Vec::new();
9097 let mut pending = Vec::new();
9098 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
9099 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
9100 Some(sub) => {
9101 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
9102 }
9103 None => true,
9104 };
9105 if is_pending {
9106 pending.push(unit.clone());
9107 }
9108 units.push(unit);
9109 }
9110 WindowState { units, pending }
9111 }
9112
9113 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
9114 let mut stmt = match self
9115 .conn
9116 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
9117 {
9118 Ok(stmt) => stmt,
9119 Err(_) => return Vec::new(),
9120 };
9121 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
9122 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9123 });
9124 match rows {
9125 Ok(rows) => rows.filter_map(Result::ok).collect(),
9126 Err(_) => Vec::new(),
9127 }
9128 }
9129
9130 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
9131 let mut stmt = match self.conn.prepare(
9132 "SELECT windows.base, windows.unit, subscriptions.tbl
9133 FROM _syncular_windows AS windows
9134 JOIN _syncular_subscriptions AS subscriptions
9135 ON subscriptions.id = windows.sub_id
9136 ORDER BY windows.base, windows.unit",
9137 ) {
9138 Ok(stmt) => stmt,
9139 Err(_) => return Vec::new(),
9140 };
9141 let rows = stmt.query_map([], |row| {
9142 Ok((
9143 row.get::<_, String>(0)?,
9144 row.get::<_, String>(1)?,
9145 row.get::<_, String>(2)?,
9146 ))
9147 });
9148 match rows {
9149 Ok(rows) => rows.filter_map(Result::ok).collect(),
9150 Err(_) => Vec::new(),
9151 }
9152 }
9153
9154 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
9155 self.conn
9156 .query_row(
9157 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
9158 rusqlite::params![sub_id],
9159 |row| Ok((row.get(0)?, row.get(1)?)),
9160 )
9161 .ok()
9162 }
9163
9164 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
9165 self.conn
9166 .execute(
9167 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
9168 rusqlite::params![base_key, unit, sub_id],
9169 )
9170 .map(|_| ())
9171 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
9172 }
9173
9174 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
9175 self.conn
9176 .execute(
9177 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
9178 rusqlite::params![base_key, unit],
9179 )
9180 .map(|_| ())
9181 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
9182 }
9183
9184 fn evict_unit(
9191 &mut self,
9192 base_key: &str,
9193 base: &WindowBase,
9194 unit: &str,
9195 sub_id: &str,
9196 ) -> Result<bool, String> {
9197 let effective = self
9198 .subs
9199 .iter()
9200 .find(|s| s.id == sub_id)
9201 .and_then(|s| s.effective.clone())
9202 .unwrap_or_else(|| unit_scopes(base, unit));
9203 let pinned = self.pinned_row_ids(&base.table);
9204 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
9205 self.delete_window_unit(base_key, unit)?;
9206 self.conn
9207 .execute(
9208 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
9209 [sub_id],
9210 )
9211 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9212 self.subs.retain(|sub| sub.id != sub_id);
9213 if deferred {
9214 self.save_pending_evict(sub_id, &base.table, &effective)?;
9215 } else {
9216 self.delete_pending_evict(sub_id)?;
9217 }
9218 Ok(remaining)
9219 }
9220
9221 fn evict_scope_rows(
9225 &self,
9226 table_name: &str,
9227 effective: &[(String, Vec<String>)],
9228 pinned: &std::collections::HashSet<String>,
9229 ) -> Result<(bool, bool), String> {
9230 if effective.is_empty() {
9231 return Ok((false, false));
9232 }
9233 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
9234 let mut clauses = Vec::new();
9235 let mut params: Vec<SqlValue> = Vec::new();
9236 for (variable, values) in effective {
9237 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
9238 if values.is_empty() {
9239 return Ok((false, false));
9240 }
9241 let holes = values
9242 .iter()
9243 .map(|v| {
9244 params.push(SqlValue::Text(v.clone()));
9245 "?"
9246 })
9247 .collect::<Vec<_>>()
9248 .join(", ");
9249 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
9250 }
9251 let scope = clauses.join(" AND ");
9252 let scope_params = params.len();
9253 let pk = quote_ident(&table.primary_key);
9254 if !pinned.is_empty() {
9255 let holes = pinned
9256 .iter()
9257 .map(|id| {
9258 params.push(SqlValue::Text(id.clone()));
9259 "?"
9260 })
9261 .collect::<Vec<_>>()
9262 .join(", ");
9263 clauses.push(format!("{pk} NOT IN ({holes})"));
9264 }
9265 let predicate = clauses.join(" AND ");
9266 let base = base_table(table_name);
9267 let visible = visible_table(table_name);
9268 let mut ids: Vec<SqlValue> = Vec::new();
9269 for source in [&base, &visible] {
9270 let mut stmt = self
9271 .conn
9272 .prepare_cached(&format!(
9273 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
9274 ))
9275 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9276 ids = stmt
9277 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
9278 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9279 .collect::<Result<_, _>>()
9280 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9281 if !ids.is_empty() {
9282 break;
9283 }
9284 }
9285 if !ids.is_empty() {
9286 for id in &ids {
9287 self.conn
9288 .execute(
9289 "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
9290 rusqlite::params![table_name, id],
9291 )
9292 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9293 }
9294 let holes = vec!["?"; ids.len()].join(", ");
9295 for source in [&base, &visible] {
9296 self.conn
9297 .execute(
9298 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
9299 rusqlite::params_from_iter(&ids),
9300 )
9301 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9302 }
9303 }
9304 let mut remaining = false;
9305 let mut deferred = false;
9306 for source in [&base, &visible] {
9307 remaining |= self
9308 .conn
9309 .query_row(
9310 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
9311 rusqlite::params_from_iter(¶ms),
9312 |row| row.get::<_, bool>(0),
9313 )
9314 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9315 deferred |= self
9316 .conn
9317 .query_row(
9318 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
9319 rusqlite::params_from_iter(¶ms[..scope_params]),
9320 |row| row.get::<_, bool>(0),
9321 )
9322 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9323 }
9324 Ok((remaining, deferred))
9325 }
9326
9327 fn drain_pending_evictions(&mut self) -> Result<(), String> {
9329 let pending = self.load_pending_evictions()?;
9330 if pending.is_empty() {
9331 return Ok(());
9332 }
9333 self.rebuild_overlay_if_dirty();
9334 for (sub_id, table_name, effective) in pending {
9335 if self.schema.table(&table_name).is_none() {
9336 self.delete_pending_evict(&sub_id)?;
9337 continue;
9338 }
9339 loop {
9340 std::thread::yield_now();
9341 self.begin_observation("syncular_evict")?;
9342 let pinned = self.pinned_row_ids(&table_name);
9343 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
9344 let (remaining, deferred) = match result {
9345 Ok(result) => result,
9346 Err(error) => {
9347 self.rollback_observation("syncular_evict");
9348 return Err(error);
9349 }
9350 };
9351 if !deferred {
9352 if let Err(error) = self.delete_pending_evict(&sub_id) {
9353 self.rollback_observation("syncular_evict");
9354 return Err(error);
9355 }
9356 }
9357 let mut batch = ChangeAccumulator::default();
9358 self.record_scope_map(&mut batch, &table_name, &effective);
9359 if let Err(error) = self.finish_observation("syncular_evict", batch) {
9360 self.rollback_observation("syncular_evict");
9361 return Err(error);
9362 }
9363 if !remaining {
9364 break;
9365 }
9366 }
9367 }
9368 Ok(())
9369 }
9370
9371 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
9374 let mut pinned = std::collections::HashSet::new();
9375 for commit in self
9376 .outbox
9377 .iter()
9378 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
9379 {
9380 for op in &commit.ops {
9381 if op.table == table {
9382 pinned.insert(op.row_id.clone());
9383 }
9384 }
9385 }
9386 pinned
9387 }
9388
9389 fn save_pending_evict(
9390 &self,
9391 sub_id: &str,
9392 table: &str,
9393 effective: &[(String, Vec<String>)],
9394 ) -> Result<(), String> {
9395 self.conn.execute(
9396 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
9397 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
9398 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
9399 }
9400
9401 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
9402 self.conn
9403 .execute(
9404 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
9405 rusqlite::params![sub_id],
9406 )
9407 .map(|_| ())
9408 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
9409 }
9410
9411 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
9412 let mut stmt = self
9413 .conn
9414 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
9415 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9416 let rows = stmt
9417 .query_map([], |row| {
9418 Ok((
9419 row.get::<_, String>(0)?,
9420 row.get::<_, String>(1)?,
9421 row.get::<_, String>(2)?,
9422 ))
9423 })
9424 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9425 rows.map(|row| {
9426 let (sub_id, table, json) =
9427 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9428 let value: Value = serde_json::from_str(&json)
9429 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
9430 let effective = json_to_scope_map(&value)
9431 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
9432 Ok((sub_id, table, effective))
9433 })
9434 .collect()
9435 }
9436
9437 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
9439 self.retain_failed_commits = enabled;
9440 for (commit, _) in &self.failed_commits {
9441 for operation in &commit.ops {
9442 self.overlay_dirty.table(&operation.table);
9443 }
9444 }
9445 self.failed_commits = self.load_failed_commits()?;
9446 for (commit, _) in &self.failed_commits {
9447 for operation in &commit.ops {
9448 self.overlay_dirty.table(&operation.table);
9449 }
9450 }
9451 Ok(())
9452 }
9453
9454 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
9455 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
9456 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9457 let rows = statement
9458 .query_map([], |row| {
9459 Ok((
9460 row.get::<_, String>(0)?,
9461 row.get::<_, String>(1)?,
9462 row.get::<_, String>(2)?,
9463 ))
9464 })
9465 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9466 rows.map(|row| {
9467 let (id, operations, initial) =
9468 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9469 let operations: Vec<CommitOperation> =
9470 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
9471 let initial =
9472 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
9473 Ok((
9474 OutboxCommit {
9475 client_commit_id: id,
9476 ops: operations
9477 .into_iter()
9478 .map(|op| OutboxOp {
9479 upsert: op.op == "upsert",
9480 table: op.table,
9481 row_id: op.row_id,
9482 base_version: op.base_version,
9483 values: op.values,
9484 })
9485 .collect(),
9486 },
9487 initial,
9488 ))
9489 })
9490 .collect()
9491 }
9492
9493 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
9494 let mut initial = Map::new();
9495 for operation in operations {
9496 let table = self
9497 .schema
9498 .table(&operation.table)
9499 .ok_or_else(|| "sync.unknown_table".to_owned())?;
9500 if let Some((values, _)) = self
9501 .stored_row(table, &operation.row_id, true)
9502 .or_else(|| self.visible_row(table, &operation.row_id))
9503 {
9504 let mut intended: Map<String, Value> = table
9505 .columns
9506 .iter()
9507 .zip(values.iter())
9508 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
9509 .collect();
9510 if let Some(patch) = &operation.values {
9511 intended.extend(patch.clone());
9512 }
9513 initial.insert(
9514 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
9515 .map_err(|_| "sync.local_corrupt".to_owned())?,
9516 Value::Object(intended),
9517 );
9518 }
9519 }
9520 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
9521 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
9522 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
9523 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9524 self.failed_commits.push((
9525 OutboxCommit {
9526 client_commit_id: id.to_owned(),
9527 ops: operations.to_vec(),
9528 },
9529 initial,
9530 ));
9531 Ok(())
9532 }
9533
9534 fn drop_failed_commits(
9535 &mut self,
9536 matches: impl Fn(&str, &Map<String, Value>) -> bool,
9537 ) -> Result<Vec<OutboxCommit>, String> {
9538 let mut doomed = self.drop_acknowledged_rows(&matches)?;
9539 for (commit, initial) in &self.failed_commits {
9540 for operation in &commit.ops {
9541 let key =
9542 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
9543 .map_err(|_| "sync.local_corrupt".to_owned())?;
9544 if initial
9545 .get(&key)
9546 .and_then(Value::as_object)
9547 .is_some_and(|values| matches(&operation.table, values))
9548 {
9549 doomed.push(commit.clone());
9550 break;
9551 }
9552 }
9553 }
9554 for commit in &doomed {
9555 self.conn
9556 .execute(
9557 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
9558 rusqlite::params![commit.client_commit_id],
9559 )
9560 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9561 }
9562 for commit in &doomed {
9563 self.conn.execute("UPDATE _syncular_commit_outcomes SET operations_json = NULL, results_json = '[]' WHERE client_commit_id = ?1", rusqlite::params![commit.client_commit_id]).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9564 }
9565 self.conflicts.retain(|record| {
9566 !doomed
9567 .iter()
9568 .any(|commit| commit.client_commit_id == record.client_commit_id)
9569 });
9570 self.rejections.retain(|record| {
9571 !doomed
9572 .iter()
9573 .any(|commit| commit.client_commit_id == record.client_commit_id)
9574 });
9575 self.failed_commits.retain(|(commit, _)| {
9576 !doomed
9577 .iter()
9578 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
9579 });
9580 for commit in &doomed {
9581 for operation in &commit.ops {
9582 self.overlay_dirty.table(&operation.table);
9583 }
9584 }
9585 Ok(doomed)
9586 }
9587
9588 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
9589 if mutations.is_empty() {
9590 return Err("a commit must contain at least one operation (§6.1)".to_owned());
9591 }
9592 let mut ops = Vec::with_capacity(mutations.len());
9593 for mutation in mutations {
9594 match mutation {
9595 Mutation::Patch {
9596 table,
9597 values,
9598 base_version,
9599 } => {
9600 let schema_table = self
9601 .schema
9602 .table(&table)
9603 .ok_or_else(|| "sync.unknown_table".to_owned())?;
9604 let values = normalize_values_casing(schema_table, values)?;
9605 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
9606 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
9607 }
9608 Mutation::Upsert {
9609 table,
9610 values,
9611 base_version,
9612 } => {
9613 let schema_table = self
9614 .schema
9615 .table(&table)
9616 .ok_or_else(|| format!("unknown table {table:?}"))?;
9617 let values = normalize_values_casing(schema_table, values)?;
9621 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
9622 let values = full_row_values(schema_table, values)?;
9625 self.validate_author_encode(schema_table, &row_id, &values)?;
9631 ops.push(OutboxOp {
9632 upsert: true,
9633 table,
9634 row_id,
9635 base_version,
9636 values: Some(values),
9637 });
9638 }
9639 Mutation::Delete {
9640 table,
9641 row_id,
9642 base_version,
9643 } => {
9644 if self.schema.table(&table).is_none() {
9645 return Err(format!("unknown table {table:?}"));
9646 }
9647 ops.push(OutboxOp {
9648 upsert: false,
9649 table,
9650 row_id,
9651 base_version,
9652 values: None,
9653 });
9654 }
9655 }
9656 }
9657 self.record_outbox_commit(ops)
9658 }
9659
9660 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
9661 let commit = OutboxCommit {
9662 client_commit_id: uuid::Uuid::new_v4().to_string(),
9663 ops,
9664 };
9665 self.begin_observation("syncular_mutation")?;
9666 let mut batch = ChangeAccumulator::default();
9667 for op in &commit.ops {
9668 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
9669 if let Some(values) = &op.values {
9670 if let Some(table) = self.schema.table(&op.table) {
9671 for scope in &table.scope_variables {
9672 if let Some(Value::String(value)) = values.get(&scope.column) {
9673 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
9674 precise = true;
9675 }
9676 }
9677 }
9678 }
9679 if !precise {
9680 batch.table(&op.table);
9681 }
9682 }
9683 if let Err(error) = self.persist_outbox_insert(&commit) {
9684 self.rollback_observation("syncular_mutation");
9685 return Err(error);
9686 }
9687 let was_dirty = self.overlay_dirty.snapshot();
9688 if self.overlay_dirty.get() {
9693 self.rebuild_overlay();
9694 }
9695 if let Some(error) = commit
9696 .ops
9697 .iter()
9698 .find_map(|op| self.apply_outbox_op(op).err())
9699 {
9700 self.rollback_observation("syncular_mutation");
9701 self.overlay_dirty.restore(was_dirty.clone());
9702 return Err(error);
9703 }
9704 let id = commit.client_commit_id.clone();
9705 self.outbox.push(commit);
9706 batch.status = true;
9707 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
9708 self.rollback_observation("syncular_mutation");
9709 self.outbox.pop();
9710 self.overlay_dirty.restore(was_dirty.clone());
9711 return Err(error);
9712 }
9713 Ok(id)
9714 }
9715
9716 pub fn patch(
9721 &mut self,
9722 table: &str,
9723 row_id: &str,
9724 partial: Map<String, Value>,
9725 base_version: Option<i64>,
9726 ) -> Result<String, String> {
9727 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
9728 self.record_outbox_commit(vec![operation])
9729 }
9730
9731 fn prepare_patch(
9732 &self,
9733 table: &str,
9734 row_id: &str,
9735 partial: Map<String, Value>,
9736 base_version: Option<i64>,
9737 ) -> Result<OutboxOp, String> {
9738 let schema_table = self
9739 .schema
9740 .table(table)
9741 .ok_or_else(|| format!("unknown table {table:?}"))?;
9742 let partial = normalize_values_casing(schema_table, partial)?;
9743 if let Some(pk) = partial.get(&schema_table.primary_key) {
9744 if pk.as_str() != Some(row_id) {
9745 return Err(format!(
9746 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
9747 ));
9748 }
9749 }
9750 let exists: bool = self
9753 .conn
9754 .query_row(
9755 &format!(
9756 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
9757 visible_table(table),
9758 row_id_predicate(schema_table)
9759 ),
9760 rusqlite::params![row_id],
9761 |row| row.get(0),
9762 )
9763 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9764 if !exists {
9765 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
9766 }
9767 for scope in &schema_table.scope_variables {
9777 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
9778 continue;
9779 }
9780 let Some(column) = schema_table
9781 .columns
9782 .iter()
9783 .find(|column| column.name == scope.column)
9784 else {
9785 continue;
9786 };
9787 let stored = self
9788 .conn
9789 .query_row(
9790 &format!(
9791 "SELECT {} FROM {} WHERE {}",
9792 quote_ident(&scope.column),
9793 visible_table(&schema_table.name),
9794 row_id_predicate(schema_table)
9795 ),
9796 rusqlite::params![row_id],
9797 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
9798 )
9799 .ok();
9800 let incoming = json_to_column_value(column, partial.get(&scope.column))
9805 .ok()
9806 .map(|value| column_value_to_json(&value));
9807 let equal = stored.as_ref().is_some_and(|stored| {
9811 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
9812 });
9813 if !equal {
9814 return Err(format!(
9815 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
9816 scope.column
9817 ));
9818 }
9819 }
9820 let mut values = partial;
9821 for scope in &schema_table.scope_variables {
9824 values.remove(&scope.column);
9825 }
9826 values.insert(
9827 schema_table.primary_key.clone(),
9828 Value::from(row_id.to_owned()),
9829 );
9830 self.validate_author_encode(schema_table, row_id, &values)?;
9836 Ok(OutboxOp {
9837 upsert: true,
9838 table: table.to_owned(),
9839 row_id: row_id.to_owned(),
9840 base_version,
9841 values: Some(values),
9842 })
9843 }
9844
9845 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
9850 let selector = self.encryption.key_id_columns.get(&table.name)?;
9851 let index = table
9852 .columns
9853 .iter()
9854 .position(|column| &column.name == selector)?;
9855 let sql = format!(
9856 "SELECT {} FROM {} WHERE {}",
9857 quote_ident(selector),
9858 visible_table(&table.name),
9859 row_id_predicate(table)
9860 );
9861 let key: Option<String> = self
9862 .conn
9863 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
9864 .ok()?;
9865 let key = key.filter(|key| !key.is_empty())?;
9866 let mut fallback: Row = vec![None; table.columns.len()];
9867 fallback[index] = Some(ColumnValue::String(key));
9868 Some(fallback)
9869 }
9870
9871 fn validate_author_encode(
9876 &self,
9877 table: &TableSchema,
9878 row_id: &str,
9879 values: &Map<String, Value>,
9880 ) -> Result<(), String> {
9881 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
9882 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
9883 result => result.map(|_| ()),
9884 }
9885 }
9886
9887 fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
9893 if !self
9894 .schema
9895 .tables
9896 .iter()
9897 .any(|table| table.has_encrypted_columns())
9898 {
9899 return Ok(false);
9900 }
9901 let failures = self
9902 .outbox
9903 .iter()
9904 .filter_map(|commit| {
9905 commit
9906 .ops
9907 .iter()
9908 .find_map(|op| {
9909 let values = op.values.as_ref().filter(|_| op.upsert)?;
9910 let table = self.schema.table(&op.table)?;
9911 let fallback = self.stored_key_fallback(table, &op.row_id);
9912 match encode_sparse_row_json(
9913 table,
9914 &op.row_id,
9915 values,
9916 &self.encryption,
9917 fallback.as_ref(),
9918 ) {
9919 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
9920 _ => None,
9921 }
9922 })
9923 .map(|error| (commit.clone(), error))
9924 })
9925 .collect::<Vec<_>>();
9926 if failures.is_empty() {
9927 return Ok(false);
9928 }
9929 for (commit, message) in failures {
9930 if self.retain_failed_commits {
9931 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
9932 }
9933 let rejection = RejectionRecord {
9934 client_commit_id: commit.client_commit_id.clone(),
9935 op_index: 0,
9936 code: ENCRYPT_FAILED_CODE.to_owned(),
9937 message,
9938 retryable: false,
9939 details: None,
9940 operation: commit.ops.first().map(CommitOperation::from),
9941 };
9942 self.persist_commit_outcome(
9943 &commit.client_commit_id,
9944 CommitOutcomeStatus::Rejected,
9945 &[CommitOperationOutcome::Error {
9946 rejection: rejection.clone(),
9947 }],
9948 Some(&commit.ops),
9949 )?;
9950 self.delete_outbox_persisted(&commit.client_commit_id)?;
9951 self.outbox
9952 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
9953 self.rejections.push(rejection);
9954 }
9955 self.prune_commit_outcomes()?;
9956 self.rebuild_overlay_if_dirty();
9957 Ok(true)
9958 }
9959
9960 pub fn pending_commit_ids(&self) -> Vec<String> {
9961 self.outbox
9962 .iter()
9963 .map(|c| c.client_commit_id.clone())
9964 .collect()
9965 }
9966
9967 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
9971 let mut payloads = Vec::new();
9972 for commit in &self.outbox {
9973 for op in &commit.ops {
9974 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
9975 continue;
9976 };
9977 let Some(table) = self.schema.table(&op.table) else {
9978 continue;
9979 };
9980 let fallback = self.stored_key_fallback(table, &op.row_id);
9981 if let Ok(payload) = encode_sparse_row_json(
9982 table,
9983 &op.row_id,
9984 values,
9985 &self.encryption,
9986 fallback.as_ref(),
9987 ) {
9988 payloads.push(payload);
9989 }
9990 }
9991 }
9992 payloads
9993 }
9994
9995 pub fn conflicts(&self) -> &[ConflictRecord] {
9996 &self.conflicts
9997 }
9998
9999 pub fn rejections(&self) -> &[RejectionRecord] {
10000 &self.rejections
10001 }
10002
10003 pub fn resolve_commit_outcome(
10004 &mut self,
10005 input: ResolveCommitOutcomeInput,
10006 ) -> Result<CommitOutcome, String> {
10007 let current = self
10008 .commit_outcome(&input.client_commit_id)?
10009 .ok_or_else(|| {
10010 format!(
10011 "sync.outcome_not_found: no durable outcome exists for {:?}",
10012 input.client_commit_id
10013 )
10014 })?;
10015 if current.resolution != CommitOutcomeResolution::Active {
10016 return Ok(current);
10017 }
10018 if input.resolution == CommitOutcomeResolution::Active {
10019 return Err("sync.invalid_request: resolution must leave active state".to_owned());
10020 }
10021 match input.resolution {
10022 CommitOutcomeResolution::Superseded => {
10023 let replacement = input
10024 .replacement_client_commit_id
10025 .as_deref()
10026 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
10027 .ok_or_else(|| {
10028 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
10029 .to_owned()
10030 })?;
10031 let _ = replacement;
10032 }
10033 _ if input.replacement_client_commit_id.is_some() => {
10034 return Err(
10035 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
10036 .to_owned(),
10037 );
10038 }
10039 _ => {}
10040 }
10041 let allowed = match current.status {
10042 CommitOutcomeStatus::Conflict => matches!(
10043 input.resolution,
10044 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
10045 ),
10046 CommitOutcomeStatus::Rejected => {
10047 matches!(
10048 input.resolution,
10049 CommitOutcomeResolution::Superseded
10050 | CommitOutcomeResolution::ResolvedKeepServer
10051 )
10052 }
10053 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
10054 input.resolution == CommitOutcomeResolution::Dismissed
10055 }
10056 };
10057 if !allowed {
10058 return Err(format!(
10059 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
10060 input.resolution, current.status
10061 ));
10062 }
10063
10064 self.begin_observation("syncular_outcome_resolution")?;
10065 let previous_failed = self.failed_commits.clone();
10066 let previous_conflicts = self.conflicts.clone();
10067 let previous_rejections = self.rejections.clone();
10068 let result = (|| {
10069 self.conn
10070 .execute(
10071 "UPDATE _syncular_commit_outcomes
10072 SET resolution = ?1, resolved_at_ms = ?2,
10073 replacement_client_commit_id = ?3
10074 WHERE client_commit_id = ?4 AND resolution = 'active'",
10075 rusqlite::params![
10076 Self::outcome_resolution_name(input.resolution),
10077 self.clock_now_ms(),
10078 input.replacement_client_commit_id,
10079 input.client_commit_id
10080 ],
10081 )
10082 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10083 let resolved = self
10084 .commit_outcome(¤t.client_commit_id)?
10085 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
10086 self.conn
10087 .execute(
10088 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
10089 rusqlite::params![input.client_commit_id],
10090 )
10091 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10092 for (commit, _) in &self.failed_commits {
10093 if commit.client_commit_id == input.client_commit_id {
10094 for operation in &commit.ops {
10095 self.overlay_dirty.table(&operation.table);
10096 }
10097 }
10098 }
10099 self.failed_commits
10100 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
10101 self.prune_commit_outcomes()?;
10102 Ok(resolved)
10103 })();
10104 let resolved = match result {
10105 Ok(outcome) => outcome,
10106 Err(error) => {
10107 self.rollback_observation("syncular_outcome_resolution");
10108 self.failed_commits = previous_failed;
10109 self.conflicts = previous_conflicts;
10110 self.rejections = previous_rejections;
10111 return Err(error);
10112 }
10113 };
10114 self.conflicts
10115 .retain(|record| record.client_commit_id != current.client_commit_id);
10116 self.rejections
10117 .retain(|record| record.client_commit_id != current.client_commit_id);
10118 let mut batch = ChangeAccumulator {
10119 conflicts: current.status == CommitOutcomeStatus::Conflict,
10120 rejections: current.status == CommitOutcomeStatus::Rejected,
10121 outcomes: true,
10122 ..ChangeAccumulator::default()
10123 };
10124 if let Some(operations) = ¤t.operations {
10125 for operation in operations {
10126 batch.table(&operation.table);
10127 }
10128 }
10129 self.rebuild_overlay_if_dirty();
10130 if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
10131 self.rollback_observation("syncular_outcome_resolution");
10132 self.failed_commits = previous_failed;
10133 self.conflicts = previous_conflicts;
10134 self.rejections = previous_rejections;
10135 return Err(error);
10136 }
10137 Ok(resolved)
10138 }
10139
10140 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
10141 self.schema_floor.as_ref()
10142 }
10143
10144 pub fn lease_state(&self) -> Option<&LeaseState> {
10146 self.lease_state.as_ref()
10147 }
10148
10149 fn record_lease_error(&mut self, code: &str) {
10152 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
10153 return;
10154 }
10155 let mut next = self.lease_state.clone().unwrap_or_default();
10156 next.error_code = Some(code.to_owned());
10157 self.set_lease_state(Some(next));
10158 }
10159
10160 fn set_lease_state(&mut self, next: Option<LeaseState>) {
10161 if self.lease_state == next {
10162 return;
10163 }
10164 if self.begin_observation("syncular_lease").is_err() {
10165 return;
10166 }
10167 self.lease_state = next;
10168 if let Some(lease) = &self.lease_state {
10169 if let Ok(json) = serde_json::to_string(lease) {
10170 self.set_meta(LEASE_STATE_KEY, &json);
10171 }
10172 } else {
10173 self.delete_meta(LEASE_STATE_KEY);
10174 }
10175 let batch = ChangeAccumulator {
10176 status: true,
10177 ..ChangeAccumulator::default()
10178 };
10179 if self.finish_observation("syncular_lease", batch).is_err() {
10180 self.rollback_observation("syncular_lease");
10181 }
10182 }
10183
10184 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
10185 if self.schema_floor == next {
10186 return;
10187 }
10188 if self.begin_observation("syncular_schema_floor").is_err() {
10189 return;
10190 }
10191 self.schema_floor = next;
10192 self.stopped = self.schema_floor.is_some();
10193 if let Some(floor) = &self.schema_floor {
10194 if let Ok(json) = serde_json::to_string(floor) {
10195 self.set_meta(SCHEMA_FLOOR_KEY, &json);
10196 }
10197 } else {
10198 self.delete_meta(SCHEMA_FLOOR_KEY);
10199 }
10200 let batch = ChangeAccumulator {
10201 status: true,
10202 ..ChangeAccumulator::default()
10203 };
10204 if self
10205 .finish_observation("syncular_schema_floor", batch)
10206 .is_err()
10207 {
10208 self.rollback_observation("syncular_schema_floor");
10209 }
10210 }
10211
10212 fn set_upgrading(&mut self, value: bool) {
10213 if self.upgrading == value {
10214 return;
10215 }
10216 if self.begin_observation("syncular_upgrading").is_err() {
10217 return;
10218 }
10219 self.upgrading = value;
10220 let batch = ChangeAccumulator {
10221 status: true,
10222 ..ChangeAccumulator::default()
10223 };
10224 if self
10225 .finish_observation("syncular_upgrading", batch)
10226 .is_err()
10227 {
10228 self.rollback_observation("syncular_upgrading");
10229 }
10230 }
10231
10232 pub fn sync_needed(&self) -> bool {
10233 self.sync_needed
10234 }
10235
10236 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
10237 let sub = self.subs.iter().find(|s| s.id == id)?;
10238 Some(SubscriptionStateView {
10239 id: sub.id.clone(),
10240 table: sub.table.clone(),
10241 status: sub.state.name().to_owned(),
10242 cursor: sub.cursor,
10243 has_resume_token: sub.bootstrap_state.is_some(),
10244 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
10245 reason_code: sub.reason_code.clone(),
10246 })
10247 }
10248
10249 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
10250 let schema_table = self
10251 .schema
10252 .table(table)
10253 .ok_or_else(|| format!("unknown table {table:?}"))?;
10254 let sql = format!(
10255 "SELECT * FROM {} ORDER BY {} ASC",
10256 visible_table(table),
10257 quote_ident(&schema_table.primary_key)
10258 );
10259 let mut stmt = self
10260 .conn
10261 .prepare(&sql)
10262 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10263 let mut rows = stmt
10264 .query([])
10265 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10266 let mut out = Vec::new();
10267 while let Some(row) = rows
10268 .next()
10269 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
10270 {
10271 let mut values = Map::new();
10272 for (i, column) in schema_table.columns.iter().enumerate() {
10273 let value = row
10274 .get_ref(i)
10275 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10276 values.insert(column.name.clone(), sql_ref_to_json(column, value));
10277 }
10278 let version: i64 = row
10279 .get(schema_table.columns.len())
10280 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10281 let row_id = match values.get(&schema_table.primary_key) {
10282 Some(Value::String(s)) => s.clone(),
10283 Some(Value::Number(n)) => n.to_string(),
10284 Some(Value::Bool(b)) => b.to_string(),
10285 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
10286 };
10287 out.push(RowState {
10288 row_id,
10289 version,
10290 values,
10291 });
10292 }
10293 Ok(out)
10294 }
10295
10296 #[cfg(feature = "crdt-yjs")]
10312 fn crdt_column_bytes(
10313 &self,
10314 table: &str,
10315 row_id: &str,
10316 column: &str,
10317 ) -> Result<Option<Vec<u8>>, String> {
10318 let schema_table = self
10319 .schema
10320 .table(table)
10321 .ok_or_else(|| format!("unknown table {table:?}"))?;
10322 let col = schema_table
10323 .columns
10324 .iter()
10325 .find(|c| c.name == column)
10326 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
10327 if col.ty != ColumnType::Crdt {
10328 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
10329 }
10330 let sql = format!(
10331 "SELECT {} FROM {} WHERE {}",
10332 quote_ident(column),
10333 visible_table(table),
10334 row_id_predicate(schema_table)
10335 );
10336 let bytes: Option<Vec<u8>> = self
10337 .conn
10338 .prepare_cached(&sql)
10339 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
10340 .query_row(rusqlite::params![row_id], |row| {
10341 row.get::<_, Option<Vec<u8>>>(0)
10342 })
10343 .map_err(|e| match e {
10344 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
10345 other => other.to_string(),
10346 })?;
10347 Ok(bytes)
10348 }
10349
10350 #[cfg(feature = "crdt-yjs")]
10354 pub fn crdt_text(
10355 &self,
10356 table: &str,
10357 row_id: &str,
10358 column: &str,
10359 name: &str,
10360 ) -> Result<String, String> {
10361 let bytes = self
10362 .crdt_column_bytes(table, row_id, column)?
10363 .unwrap_or_default();
10364 crate::crdt::text(&bytes, name)
10365 }
10366
10367 #[cfg(feature = "crdt-yjs")]
10371 pub fn crdt_insert_text(
10372 &mut self,
10373 table: &str,
10374 row_id: &str,
10375 column: &str,
10376 name: &str,
10377 index: u32,
10378 value: &str,
10379 ) -> Result<String, String> {
10380 let current = self
10381 .crdt_column_bytes(table, row_id, column)?
10382 .unwrap_or_default();
10383 let update = crate::crdt::insert_text(¤t, name, index, value)?;
10384 self.crdt_push_update(table, row_id, column, &update)
10385 }
10386
10387 #[cfg(feature = "crdt-yjs")]
10391 pub fn crdt_delete_text(
10392 &mut self,
10393 table: &str,
10394 row_id: &str,
10395 column: &str,
10396 name: &str,
10397 index: u32,
10398 len: u32,
10399 ) -> Result<String, String> {
10400 let current = self
10401 .crdt_column_bytes(table, row_id, column)?
10402 .unwrap_or_default();
10403 let update = crate::crdt::delete_text(¤t, name, index, len)?;
10404 self.crdt_push_update(table, row_id, column, &update)
10405 }
10406
10407 #[cfg(feature = "crdt-yjs")]
10412 pub fn crdt_apply_update(
10413 &mut self,
10414 table: &str,
10415 row_id: &str,
10416 column: &str,
10417 update: &[u8],
10418 ) -> Result<String, String> {
10419 let current = self
10420 .crdt_column_bytes(table, row_id, column)?
10421 .unwrap_or_default();
10422 let next = crate::crdt::apply_update(¤t, update)?;
10423 self.crdt_push_update(table, row_id, column, &next)
10424 }
10425
10426 #[cfg(feature = "crdt-yjs")]
10433 fn crdt_push_update(
10434 &mut self,
10435 table: &str,
10436 row_id: &str,
10437 column: &str,
10438 crdt_bytes: &[u8],
10439 ) -> Result<String, String> {
10440 let mut bytes_obj = Map::new();
10441 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
10442 let mut partial = Map::new();
10443 partial.insert(column.to_owned(), Value::Object(bytes_obj));
10444 self.patch(table, row_id, partial, None)
10445 }
10446
10447 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
10462 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
10463 }
10464
10465 #[cfg(feature = "bench-internals")]
10467 #[doc(hidden)]
10468 pub fn benchmark_connection(&mut self) -> &mut Connection {
10469 &mut self.conn
10470 }
10471
10472 #[cfg(feature = "bench-internals")]
10474 #[doc(hidden)]
10475 pub fn benchmark_phases(
10476 &self,
10477 enabled: Option<bool>,
10478 reset: bool,
10479 ) -> Result<Option<Value>, String> {
10480 self.benchmark_phases.configure(enabled, reset)?;
10481 Ok(self.benchmark_phases.snapshot())
10482 }
10483
10484 pub fn authority_snapshot(&self) -> Result<Value, String> {
10486 validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
10487 if self.limits.authority_reads.is_empty() {
10488 return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
10489 }
10490 self.conn
10491 .execute_batch("SAVEPOINT authority_snapshot")
10492 .map_err(|e| e.to_string())?;
10493 let result = (|| {
10494 let revision = self
10495 .get_meta(LOCAL_REVISION_KEY)
10496 .ok_or("sync.local_corrupt: authority revision is missing")?;
10497 let parsed_revision = revision
10498 .parse::<u64>()
10499 .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
10500 if parsed_revision.to_string() != revision {
10501 return Err("sync.local_corrupt: authority revision is invalid".into());
10502 }
10503 let mut tables = Vec::new();
10504 for read in &self.limits.authority_reads {
10505 let table = self
10506 .schema
10507 .table(&read.table)
10508 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
10509 let scalar = |column: &str, value: &Value| -> Result<String, String> {
10510 let ty = table
10511 .columns
10512 .iter()
10513 .find(|c| c.name == column)
10514 .expect("validated authority column")
10515 .ty;
10516 if ty == ColumnType::Boolean {
10517 if let Some(value) = value.as_bool() {
10518 return Ok(value.to_string());
10519 }
10520 return match value.as_i64() {
10521 Some(0) => Ok("false".into()),
10522 Some(1) => Ok("true".into()),
10523 _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
10524 };
10525 }
10526 match value {
10527 Value::String(s) => Ok(s.clone()),
10528 Value::Number(n) => Ok(n.to_string()),
10529 Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
10530 .get("$bigint")
10531 .and_then(Value::as_str)
10532 .map(str::to_owned)
10533 .ok_or("sync.local_corrupt: authority integer is invalid".into()),
10534 _ => {
10535 Err("sync.local_corrupt: authority scalar is missing or invalid".into())
10536 }
10537 }
10538 };
10539 let matches = |row: &QueryRow| -> Result<bool, String> {
10540 for (variable, values) in &read.scopes {
10541 let column = table
10542 .scope_column(variable)
10543 .expect("validated authority scope");
10544 let value = row
10545 .get(column)
10546 .ok_or("sync.local_corrupt: authority scope column is missing")?;
10547 if value.is_null() || !values.contains(&scalar(column, value)?) {
10548 return Ok(false);
10549 }
10550 }
10551 Ok(true)
10552 };
10553 let selected = read
10554 .columns
10555 .iter()
10556 .map(|c| quote_ident(c))
10557 .collect::<Vec<_>>()
10558 .join(",");
10559 let mut intent = BTreeSet::new();
10560 let mut relevant = BTreeSet::new();
10561 for row in query_connection(&self.conn,
10562 "SELECT id,values_json FROM (SELECT json_extract(value,'$.table') AS tbl,json_extract(value,'$.rowId') AS id,json_extract(value,'$.values') AS values_json FROM _syncular_outbox,json_each(ops_json) UNION ALL SELECT json_extract(value,'$.table'),json_extract(value,'$.rowId'),json_extract(value,'$.values') FROM _syncular_failed_commits,json_each(operations_json) UNION ALL SELECT tbl,id,intent_json FROM _syncular_acked_rows) WHERE tbl=?",
10563 &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
10564 let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
10565 intent.insert(id.clone());
10566 if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
10567 let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
10568 if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
10569 }
10570 }
10571 let mut rows = BTreeMap::new();
10572 for full in [base_table(&read.table), visible_table(&read.table)] {
10573 for mut values in query_connection(
10574 &self.conn,
10575 &format!(
10576 "SELECT {selected},_syncular_version AS authority_version FROM {full}"
10577 ),
10578 &[],
10579 )
10580 .map_err(|e| e.to_string())?
10581 {
10582 if !matches(&values)? {
10583 continue;
10584 }
10585 let id = scalar(
10586 &table.primary_key,
10587 values
10588 .get(&table.primary_key)
10589 .expect("selected primary key"),
10590 )?;
10591 relevant.insert(id.clone());
10592 if full == base_table(&read.table) {
10593 let version = values
10594 .remove("authority_version")
10595 .expect("selected version");
10596 let version = version
10597 .as_i64()
10598 .ok_or("sync.local_corrupt: authority server version is invalid")?;
10599 if version >= 0 {
10600 rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
10601 }
10602 }
10603 }
10604 }
10605 intent.retain(|id| relevant.contains(id));
10606 let rows = rows.into_values().collect::<Vec<_>>();
10607 let mut persisted = Vec::new();
10608 for row in query_connection(
10609 &self.conn,
10610 "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
10611 &[Value::String(read.table.clone())],
10612 )
10613 .map_err(|e| e.message)?
10614 {
10615 let state: Value = serde_json::from_str(
10616 row.get("state_json")
10617 .and_then(Value::as_str)
10618 .ok_or("sync.local_corrupt: authority subscription state is missing")?,
10619 )
10620 .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
10621 let effective = state.get("effectiveScopes").and_then(Value::as_object);
10622 let status = state
10623 .get("status")
10624 .and_then(Value::as_str)
10625 .ok_or("sync.local_corrupt: authority subscription status is missing")?;
10626 let cursor = state
10627 .get("cursor")
10628 .and_then(Value::as_i64)
10629 .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
10630 let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
10631 && status == "active"
10632 && cursor >= 0
10633 && state.get("bootstrapState").is_none_or(Value::is_null)
10634 && state.get("params").is_none_or(Value::is_null);
10635 let exposed = effective.map(|s| {
10636 read.scopes
10637 .keys()
10638 .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
10639 .collect::<Map<_, _>>()
10640 });
10641 let requested = read
10642 .scopes
10643 .keys()
10644 .map(|v| {
10645 (
10646 v.clone(),
10647 state
10648 .get("requested")
10649 .and_then(|s| s.get(v))
10650 .cloned()
10651 .unwrap_or(json!([])),
10652 )
10653 })
10654 .collect::<Map<_, _>>();
10655 persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
10656 }
10657 fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
10658 match selectors.split_first() {
10659 None => !candidates.is_empty(),
10660 Some(((variable, values), rest)) => values.iter().all(|value| {
10661 covered(
10662 rest,
10663 &candidates
10664 .iter()
10665 .copied()
10666 .filter(|p| {
10667 p["effectiveScopes"][variable.as_str()]
10668 .as_array()
10669 .is_some_and(|held| {
10670 held.contains(&Value::String(value.clone()))
10671 || held.contains(&Value::String("*".into()))
10672 })
10673 })
10674 .collect::<Vec<_>>(),
10675 )
10676 }),
10677 }
10678 }
10679 let coverage = if covered(
10680 &read.scopes.iter().collect::<Vec<_>>(),
10681 &persisted
10682 .iter()
10683 .filter(|p| p["complete"] == true)
10684 .collect::<Vec<_>>(),
10685 ) {
10686 "complete"
10687 } else if persisted.iter().any(|p| {
10688 p["status"] == "active"
10689 && read.scopes.iter().all(|(v, values)| {
10690 values.iter().any(|value| {
10691 p["requestedScopes"][v].as_array().is_some_and(|held| {
10692 held.contains(&Value::String(value.clone()))
10693 || held.contains(&Value::String("*".into()))
10694 })
10695 })
10696 })
10697 }) {
10698 "pending"
10699 } else {
10700 "missing"
10701 };
10702 tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
10703 }
10704 Ok(
10705 json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
10706 )
10707 })();
10708 match result {
10709 Ok(snapshot) => {
10710 self.conn
10711 .execute_batch("RELEASE authority_snapshot")
10712 .map_err(|e| e.to_string())?;
10713 Ok(snapshot)
10714 }
10715 Err(error) => {
10716 self.conn
10717 .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
10718 .map_err(|e| format!("{error}; rollback failed: {e}"))?;
10719 Err(error)
10720 }
10721 }
10722 }
10723
10724 pub fn query_snapshot(
10728 &mut self,
10729 sql: &str,
10730 params: &[QueryValue],
10731 coverage: &[WindowCoverage],
10732 owner: Option<QueryOwner<'_>>,
10733 ) -> Result<QuerySnapshot, String> {
10734 let result = snapshot_connection(&self.conn, sql, params, coverage);
10735 if let Some(owner) = owner {
10736 self.record_query_read(owner, result.as_ref().err());
10737 }
10738 result.map_err(|failure| failure.to_string())
10739 }
10740
10741 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
10745 let position = self
10746 .query_failures
10747 .iter()
10748 .position(|entry| entry.id == owner.id);
10749 let Some(failure) = failure else {
10750 if let Some(index) = position {
10751 self.query_failures.remove(index);
10752 }
10753 return;
10754 };
10755 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
10756 .into_iter()
10757 .map(str::to_owned)
10758 .collect();
10759 let code = failure.code.unwrap_or("client.query_failed").to_owned();
10760 if position.is_some_and(|index| {
10761 let entry = &self.query_failures[index];
10762 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
10763 }) {
10764 return;
10765 }
10766 if let Some(index) = position {
10767 self.query_failures.remove(index);
10768 }
10769 self.query_failures.push(DiagnosticQueryFailure {
10770 id: owner.id.to_owned(),
10771 tables,
10772 code,
10773 sqlite_code: failure.sqlite_code,
10774 at_ms: self.clock_now_ms(),
10775 });
10776 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
10777 self.query_failures.remove(0);
10778 }
10779 }
10780
10781 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
10784 #[cfg(feature = "bench-internals")]
10785 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
10786 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
10787 let mut frames = vec![Frame::ReqHeader {
10788 client_id: self.client_id.clone(),
10789 schema_version: self.schema.version,
10790 log_epoch: log_epoch.clone(),
10791 }];
10792 let mut pushed_ids = Vec::new();
10793 let mut ops_in_request = 0usize;
10794 let mut deferred_commits = 0usize;
10795 #[cfg(feature = "bench-internals")]
10796 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
10797 for (index, commit) in self
10798 .outbox
10799 .iter()
10800 .take(if log_epoch.is_some() {
10801 self.outbox.len()
10802 } else {
10803 0
10804 })
10805 .enumerate()
10806 {
10807 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
10812 deferred_commits = self.outbox.len() - index;
10813 break;
10814 }
10815 ops_in_request += commit.ops.len();
10816 let operations = commit
10817 .ops
10818 .iter()
10819 .map(|op| {
10820 let payload = op.values.as_ref().and_then(|values| {
10821 let table = self.schema.table(&op.table)?;
10822 let fallback = self.stored_key_fallback(table, &op.row_id);
10831 encode_sparse_row_json(
10832 table,
10833 &op.row_id,
10834 values,
10835 &self.encryption,
10836 fallback.as_ref(),
10837 )
10838 .ok()
10839 });
10840 ssp2::model::Operation {
10841 table: op.table.clone(),
10842 row_id: op.row_id.clone(),
10843 op: if op.upsert { Op::Upsert } else { Op::Delete },
10844 base_version: op.base_version,
10845 payload,
10846 }
10847 })
10848 .collect();
10849 frames.push(Frame::PushCommit {
10850 client_commit_id: commit.client_commit_id.clone(),
10851 operations,
10852 });
10853 pushed_ids.push(commit.client_commit_id.clone());
10854 }
10855 #[cfg(feature = "bench-internals")]
10856 drop(outbox_phase);
10857 let accept = self.limits.accept.unwrap_or(if url_capable {
10860 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
10861 } else {
10862 DEFAULT_ACCEPT
10863 });
10864 frames.push(Frame::PullHeader {
10865 limit_commits: self.limits.limit_commits.unwrap_or(0),
10866 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
10867 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
10868 accept,
10869 });
10870 let mut fresh = Vec::new();
10871 for sub in &self.subs {
10872 if sub.state != SubState::Active {
10873 continue;
10874 }
10875 let mut scopes = sub.requested.clone();
10876 sort_scope_map(&mut scopes);
10877 frames.push(Frame::Subscription {
10878 id: sub.id.clone(),
10879 table: sub.table.clone(),
10880 scopes,
10881 params: sub.params.clone().map(RawJson),
10882 cursor: sub.cursor,
10883 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
10884 });
10885 fresh.push((
10886 sub.id.clone(),
10887 sub.cursor < 0 && sub.bootstrap_state.is_none(),
10888 ));
10889 }
10890 let message = Message {
10891 wire_version: WIRE_VERSION,
10892 msg_kind: MsgKind::Request,
10893 frames,
10894 };
10895 (
10896 message,
10897 RequestMeta {
10898 pushed_ids,
10899 fresh,
10900 accept,
10901 deferred_commits,
10902 },
10903 )
10904 }
10905
10906 pub fn progress(&self) -> ProgressObserver {
10910 self.progress.clone()
10911 }
10912
10913 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
10914 let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
10915 Ok(prepared) => prepared,
10916 Err(outcome) => return *outcome,
10917 };
10918 let mut next = prepared;
10919 loop {
10920 match self.apply_sync_round(next.exchange(transport)) {
10921 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
10922 crate::AppliedSyncRound::Complete {
10923 outcome, controls, ..
10924 } => {
10925 if self.transport_enabled {
10926 for text in controls {
10927 let _ = transport.realtime_send(&text);
10928 }
10929 } else {
10930 self.disconnect_realtime(transport);
10931 }
10932 return outcome;
10933 }
10934 }
10935 }
10936 }
10937
10938 fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
10939 if let SyncOutcome::Failed {
10940 error_code,
10941 message,
10942 details,
10943 } = &mut outcome
10944 {
10945 if let Some(failure) = self.storage_failure.borrow().as_ref() {
10946 let code = failure
10947 .code
10948 .expect("retained storage failures are classified");
10949 *error_code = code.to_owned();
10950 *message = match code {
10951 "client.storage_full" => "local SQLite storage is full",
10952 "client.storage_io" => "local SQLite storage I/O failed",
10953 "client.storage_corrupt" => "local SQLite storage is corrupt",
10954 _ => unreachable!("unrecognized classified storage failure"),
10955 }
10956 .to_owned();
10957 *details = failure.details();
10958 }
10959 }
10960 let retry_delay_ms = self.round_retry_delay_ms;
10961 self.progress.update(|p| match &outcome {
10962 SyncOutcome::Ok(report) if report.failed.is_empty() => {
10963 p.state = ProgressState::Complete
10964 }
10965 SyncOutcome::Ok(_) => {
10966 p.state = ProgressState::Failed;
10967 p.error_code = Some("sync.scope_revoked".into());
10968 }
10969 SyncOutcome::Failed { error_code, .. } => {
10970 p.state = ProgressState::Failed;
10971 p.error_code = Some(Self::diagnostic_code(error_code));
10972 p.retry_delay_ms = retry_delay_ms;
10973 }
10974 SyncOutcome::RealtimeUnavailable { .. } => {
10975 p.state = ProgressState::Failed;
10976 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
10977 p.retry_delay_ms = retry_delay_ms;
10978 }
10979 });
10980 let completed_at_ms = self.clock_now_ms();
10981 self.last_round = Some(match &outcome {
10982 SyncOutcome::Ok(report) => DiagnosticLastRound {
10983 status: "succeeded".to_owned(),
10984 started_at_ms,
10985 completed_at_ms,
10986 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10987 counters: Some(DiagnosticRoundCounters {
10988 pushed: report.pushed,
10989 applied: report.applied.len(),
10990 rejected: report.rejected.len(),
10991 retryable: report.retryable.len(),
10992 conflicts: report.conflicts,
10993 commits_applied: report.commits_applied,
10994 segment_rows_applied: report.segment_rows_applied,
10995 bootstrapping: report.bootstrapping.len(),
10996 resets: report.resets.len(),
10997 revoked: report.revoked.len(),
10998 failed: report.failed.len(),
10999 deferred_commits: report.deferred_commits,
11000 }),
11001 error_code: None,
11002 },
11003 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
11004 status: "failed".to_owned(),
11005 started_at_ms,
11006 completed_at_ms,
11007 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
11008 counters: None,
11009 error_code: Some(Self::diagnostic_code(error_code)),
11010 },
11011 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
11012 status: "failed".to_owned(),
11013 started_at_ms,
11014 completed_at_ms,
11015 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
11016 counters: None,
11017 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
11018 },
11019 });
11020 outcome
11021 }
11022
11023 pub fn prepare_sync_round(
11026 &mut self,
11027 url_capable: bool,
11028 ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
11029 if !self.transport_enabled {
11030 return Err(Box::new(SyncOutcome::Failed {
11031 error_code: "sync.offline".into(),
11032 message: "transport gate is closed".into(),
11033 details: None,
11034 }));
11035 }
11036 if self.active_round.is_some() {
11037 return Err(Box::new(SyncOutcome::Failed {
11038 error_code: "client.round_in_progress".into(),
11039 message: "a sync round is already in flight".into(),
11040 details: None,
11041 }));
11042 }
11043 self.storage_failure.borrow_mut().take();
11044 self.progress.start();
11045 self.round_retry_delay_ms = None;
11046 let started_at_ms = self.clock_now_ms();
11047 let prepared = (|| {
11048 if self.stopped {
11049 return Err(Box::new(SyncOutcome::Ok(SyncReport {
11050 schema_floor: self.schema_floor.clone(),
11051 ..SyncReport::default()
11052 })));
11053 }
11054 if self.security_preflight {
11055 return Err(Box::new(SyncOutcome::Failed {
11056 error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
11057 message: "security preflight must complete before syncing".into(),
11058 details: None,
11059 }));
11060 }
11061 self.set_sync_needed(false, false);
11062 let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
11063 self.pending_blob_uploads().map_err(|e| {
11064 Box::new(SyncOutcome::Failed {
11065 error_code: e.code,
11066 message: e.message,
11067 details: e.details,
11068 })
11069 })?
11070 } else {
11071 Vec::new()
11072 };
11073 self.drain_pending_evictions()
11074 .and_then(|_| self.drop_unencodable_outbox())
11075 .map_err(|message| {
11076 Box::new(SyncOutcome::Failed {
11077 error_code: "storage.failed".into(),
11078 message,
11079 details: None,
11080 })
11081 })?;
11082 if self.realtime_state != RealtimeState::Connected
11083 && self.realtime_policy == RealtimePolicy::Required
11084 {
11085 let retry_delay_ms = self.schedule_background_retry();
11086 self.realtime_retry_delay_ms = Some(retry_delay_ms);
11087 return Err(Box::new(SyncOutcome::RealtimeUnavailable {
11088 state: self.realtime_state,
11089 reason_code: self.realtime_reason_code.clone(),
11090 retry_delay_ms,
11091 }));
11092 }
11093 let (message, meta) = self.build_request(url_capable);
11094 let id = uuid::Uuid::new_v4();
11095 self.active_round = Some(id);
11096 Ok(crate::PreparedSyncRound {
11097 id,
11098 started_at_ms,
11099 message,
11100 meta,
11101 uploads: uploads.into(),
11102 realtime: self.realtime_state == RealtimeState::Connected,
11103 scopes: self
11104 .subs
11105 .iter()
11106 .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
11107 .collect(),
11108 #[cfg(feature = "bench-internals")]
11109 benchmark_phases: self.benchmark_phases.clone(),
11110 progress: self.progress.clone(),
11111 fixed_now: self.now_ms,
11112 })
11113 })();
11114 prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
11115 }
11116
11117 pub fn cancel_sync_round(&mut self) {
11120 self.active_round = None;
11121 }
11122
11123 pub fn apply_sync_round(
11126 &mut self,
11127 completed: crate::CompletedSyncRound,
11128 ) -> crate::AppliedSyncRound {
11129 let prepared = completed.prepared;
11130 if self.active_round != Some(prepared.id) || self.security_preflight {
11131 return crate::AppliedSyncRound::Complete {
11132 outcome: SyncOutcome::Failed {
11133 error_code: "client.round_cancelled".into(),
11134 message: "sync round belongs to a released client context".into(),
11135 details: None,
11136 },
11137 controls: Vec::new(),
11138 more: false,
11139 bootstrap_advanced: false,
11140 };
11141 }
11142 self.storage_failure.borrow_mut().take();
11143 let (response, mut downloads, transport_failed) = match completed.exchange {
11144 crate::round::ExchangeResult::Upload { id, result } => {
11145 let result = result.and_then(|()| {
11146 self.conn
11147 .execute(
11148 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
11149 rusqlite::params![id],
11150 )
11151 .map(|_| ())
11152 .map_err(|error| {
11153 TransportError::new(
11154 "client.failed",
11155 Self::sqlite_failure(&self.storage_failure, error),
11156 )
11157 })
11158 });
11159 match result {
11160 Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
11161 Err(TransportError {
11162 code,
11163 message,
11164 details,
11165 }) => {
11166 self.active_round = None;
11167 if Self::retryable_failure_code(&code) {
11168 self.schedule_background_retry();
11169 }
11170 let outcome = self.finish_sync_round(
11171 prepared.started_at_ms,
11172 SyncOutcome::Failed {
11173 error_code: code,
11174 message,
11175 details,
11176 },
11177 );
11178 return crate::AppliedSyncRound::Complete {
11179 outcome,
11180 controls: Vec::new(),
11181 more: false,
11182 bootstrap_advanced: false,
11183 };
11184 }
11185 }
11186 }
11187 crate::round::ExchangeResult::Reply {
11188 response,
11189 downloads,
11190 transport_failed,
11191 } => (response, downloads, transport_failed),
11192 };
11193 self.active_round = None;
11194 let before = self
11195 .subs
11196 .iter()
11197 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
11198 .collect::<Vec<_>>();
11199 let outcome = (|| {
11200 let response = match response {
11201 Ok(response) => response,
11202 Err(TransportError {
11203 code,
11204 message,
11205 details,
11206 }) => {
11207 if transport_failed {
11208 if prepared.realtime {
11209 self.set_realtime_state(RealtimeState::Lost, Some(&code));
11210 }
11211 self.record_lease_error(&code);
11212 }
11213 if Self::retryable_failure_code(&code) {
11214 let delay = self.schedule_background_retry();
11215 if prepared.realtime && transport_failed {
11216 self.realtime_retry_delay_ms = Some(delay);
11217 }
11218 }
11219 return SyncOutcome::Failed {
11220 error_code: code,
11221 message,
11222 details,
11223 };
11224 }
11225 };
11226 let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
11227 if let SyncOutcome::Ok(report) = &mut outcome {
11228 report.deferred_commits = prepared.meta.deferred_commits;
11229 }
11230 match &outcome {
11231 SyncOutcome::Ok(_) => self.reset_background_retry(),
11232 SyncOutcome::Failed { error_code, .. }
11233 if Self::retryable_failure_code(error_code) =>
11234 {
11235 self.schedule_background_retry();
11236 }
11237 _ => {}
11238 }
11239 if prepared.meta.deferred_commits > 0 {
11240 self.set_sync_needed(true, true);
11241 }
11242 self.previous_version_lifetime_check();
11243 outcome
11244 })();
11245 let more = self.transport_enabled
11246 && matches!(&outcome, SyncOutcome::Ok(report) if !report.bootstrapping.is_empty() || report.commits_applied > 0 || report.segment_rows_applied > 0 || !report.resets.is_empty() || self.sync_needed);
11247 let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
11248 && before
11249 != self
11250 .subs
11251 .iter()
11252 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
11253 .collect::<Vec<_>>();
11254 crate::AppliedSyncRound::Complete {
11255 outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
11256 controls: if self.transport_enabled {
11257 downloads.controls
11258 } else {
11259 Vec::new()
11260 },
11261 more,
11262 bootstrap_advanced,
11263 }
11264 }
11265
11266 pub fn sync_until_idle(
11267 &mut self,
11268 transport: &mut dyn Transport,
11269 max_rounds: Option<u32>,
11270 ) -> SyncOutcome {
11271 let rounds = max_rounds.unwrap_or(20).max(1);
11272 let mut aggregate = SyncReport::default();
11273 let mut spent = 0;
11274 while spent < rounds {
11275 spent += 1;
11276 let before = self
11277 .subs
11278 .iter()
11279 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
11280 .collect::<Vec<_>>();
11281 let outcome = self.sync(transport);
11282 match outcome {
11283 SyncOutcome::Failed {
11284 error_code,
11285 message,
11286 details,
11287 } => {
11288 return SyncOutcome::Failed {
11289 details,
11290 error_code,
11291 message,
11292 };
11293 }
11294 SyncOutcome::RealtimeUnavailable { .. } => return outcome,
11295 SyncOutcome::Ok(report) => {
11296 if max_rounds.is_none()
11297 && report.segment_rows_applied > 0
11298 && !report.bootstrapping.is_empty()
11299 && before
11300 != self
11301 .subs
11302 .iter()
11303 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
11304 .collect::<Vec<_>>()
11305 {
11306 spent = 0;
11307 }
11308 aggregate.merge(&report);
11309 let more = !report.bootstrapping.is_empty()
11315 || report.commits_applied > 0
11316 || report.segment_rows_applied > 0
11317 || !report.resets.is_empty()
11318 || self.sync_needed;
11319 if !more {
11320 return SyncOutcome::Ok(aggregate);
11321 }
11322 }
11323 }
11324 }
11325 SyncOutcome::Failed {
11326 details: None,
11327 error_code: "sync.invalid_request".into(),
11328 message: "sync did not reach idle within the round budget".into(),
11329 }
11330 }
11331
11332 fn process_response(
11333 &mut self,
11334 transport: &mut dyn Transport,
11335 response: Message,
11336 meta: &RequestMeta,
11337 ) -> SyncOutcome {
11338 #[cfg(feature = "bench-internals")]
11339 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
11340 let mut report = SyncReport {
11341 pushed: meta.pushed_ids.len() as u32,
11342 ..SyncReport::default()
11343 };
11344 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
11345 HashMap::new();
11346 let pushed_ids = meta
11347 .pushed_ids
11348 .iter()
11349 .map(String::as_str)
11350 .collect::<HashSet<_>>();
11351 let mut last_final_push_result_id: Option<String> = None;
11352 for frame in &response.frames {
11353 match frame {
11354 Frame::PushResultDetails {
11355 client_commit_id,
11356 entries,
11357 } => {
11358 let details = rejection_details_by_commit
11359 .entry(client_commit_id.clone())
11360 .or_default();
11361 for entry in entries {
11362 let parsed = match RejectionDetails::parse(&entry.details.0) {
11363 Ok(value) => value,
11364 Err(message) => {
11365 return SyncOutcome::Failed {
11366 details: None,
11367 error_code: "sync.invalid_request".to_owned(),
11368 message,
11369 };
11370 }
11371 };
11372 details.insert(entry.op_index, parsed);
11373 }
11374 }
11375 Frame::PushResult {
11376 client_commit_id,
11377 status,
11378 results,
11379 ..
11380 } if pushed_ids.contains(client_commit_id.as_str())
11381 && Self::push_result_is_final(*status, results) =>
11382 {
11383 last_final_push_result_id = Some(client_commit_id.clone());
11384 }
11385 _ => {}
11386 }
11387 }
11388 let mut frames = response.frames.into_iter().peekable();
11389 if response.wire_version < 2 {
11390 return SyncOutcome::Failed {
11391 details: None,
11392 error_code: "client.invalid_host_response".to_owned(),
11393 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
11394 };
11395 }
11396 match frames.next() {
11397 Some(Frame::RespHeader {
11398 required_schema_version,
11399 latest_schema_version,
11400 log_epoch,
11401 reset_required,
11402 }) => {
11403 let Some(log_epoch) = log_epoch else {
11404 return SyncOutcome::Failed {
11405 details: None,
11406 error_code: "client.invalid_host_response".to_owned(),
11407 message: "response header omits logEpoch".to_owned(),
11408 };
11409 };
11410 let Some(reset_required) = reset_required else {
11411 return SyncOutcome::Failed {
11412 details: None,
11413 error_code: "client.invalid_host_response".to_owned(),
11414 message: "response header omits resetRequired".to_owned(),
11415 };
11416 };
11417 if let Some(required) = required_schema_version {
11418 let floor = SchemaFloor {
11421 required_schema_version: Some(required),
11422 latest_schema_version,
11423 };
11424 self.set_schema_floor(Some(floor.clone()));
11425 report.schema_floor = Some(floor);
11426 return SyncOutcome::Ok(report);
11427 }
11428 if reset_required {
11429 if frames.next().is_some() {
11430 return SyncOutcome::Failed {
11431 details: None,
11432 error_code: "client.invalid_host_response".to_owned(),
11433 message: "log-epoch reset response contains body frames".to_owned(),
11434 };
11435 }
11436 match self.run_log_epoch_reset(&log_epoch) {
11437 Ok(resets) => {
11438 report.resets = resets;
11439 return SyncOutcome::Ok(report);
11440 }
11441 Err(message) => {
11442 return SyncOutcome::Failed {
11443 details: None,
11444 error_code: "sync.local_corrupt".to_owned(),
11445 message,
11446 };
11447 }
11448 }
11449 }
11450 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
11451 return SyncOutcome::Failed {
11452 details: None,
11453 error_code: "client.invalid_host_response".to_owned(),
11454 message: "server changed logEpoch without requiring a reset".to_owned(),
11455 };
11456 }
11457 }
11458 _ => {
11459 return SyncOutcome::Failed {
11460 details: None,
11461 error_code: "sync.invalid_request".to_owned(),
11462 message: "response does not start with RESP_HEADER".to_owned(),
11463 };
11464 }
11465 }
11466
11467 let mut failure: Option<(String, String)> = None;
11468 while let Some(frame) = frames.next() {
11469 match frame {
11470 frame @ Frame::PushResult { .. } => {
11471 let successful = matches!(
11472 &frame,
11473 Frame::PushResult {
11474 status: PushStatus::Applied | PushStatus::Cached,
11475 ..
11476 }
11477 );
11478 let mut results = vec![frame];
11479 if successful {
11480 while matches!(
11481 frames.peek(),
11482 Some(Frame::PushResult {
11483 status: PushStatus::Applied | PushStatus::Cached,
11484 ..
11485 })
11486 ) {
11487 if let Some(next) = frames.next() {
11488 results.push(next);
11489 }
11490 }
11491 }
11492 if self
11493 .handle_push_results(
11494 &results,
11495 &pushed_ids,
11496 &rejection_details_by_commit,
11497 &mut report,
11498 last_final_push_result_id.as_deref(),
11499 )
11500 .is_err()
11501 {
11502 failure = Some((
11503 "client.outcome_persistence_failed".to_owned(),
11504 "local commit outcome could not be persisted".to_owned(),
11505 ));
11506 break;
11507 }
11508 }
11509 Frame::PushResultDetails { .. } => {}
11510 Frame::SubStart {
11511 id,
11512 status,
11513 reason_code,
11514 effective_scopes,
11515 bootstrap: _,
11516 } => {
11517 let mut body = Vec::new();
11518 let mut sub_end: Option<(i64, Option<String>)> = None;
11519 for inner in frames.by_ref() {
11520 match inner {
11521 Frame::SubEnd {
11522 next_cursor,
11523 bootstrap_state,
11524 } => {
11525 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
11526 break;
11527 }
11528 Frame::Unknown { .. } => {}
11529 other => body.push(other),
11530 }
11531 }
11532 if let Err(SectionError::Abort(code, message)) = self.process_section(
11533 transport,
11534 &id,
11535 status,
11536 &reason_code,
11537 effective_scopes,
11538 body,
11539 sub_end,
11540 meta,
11541 &mut report,
11542 ) {
11543 failure = Some((code, message));
11544 break;
11545 }
11546 }
11547 Frame::Lease {
11548 lease_id,
11549 expires_at_ms,
11550 } => {
11551 self.set_lease_state(Some(LeaseState {
11554 lease_id: Some(lease_id),
11555 expires_at_ms: Some(expires_at_ms),
11556 error_code: None,
11557 }));
11558 }
11559 Frame::Error { code, message, .. } => {
11560 failure = Some((code, message));
11563 break;
11564 }
11565 Frame::Unknown { .. } => {}
11566 _ => {
11567 failure = Some((
11568 "sync.invalid_request".to_owned(),
11569 "unexpected frame in response".to_owned(),
11570 ));
11571 break;
11572 }
11573 }
11574 }
11575
11576 if self.overlay_dirty.get() {
11581 self.rebuild_overlay();
11582 }
11583 if let Some((error_code, message)) = failure {
11584 return SyncOutcome::Failed {
11585 details: None,
11586 error_code,
11587 message,
11588 };
11589 }
11590 if let Err(message) = self.drain_pending_evictions() {
11593 return SyncOutcome::Failed {
11594 details: None,
11595 error_code: "storage.failed".into(),
11596 message,
11597 };
11598 }
11599 self.ack_after_pull(transport);
11600 if self.upgrading && report.bootstrapping.is_empty() {
11603 self.set_upgrading(false);
11604 }
11605 SyncOutcome::Ok(report)
11606 }
11607
11608 fn handle_push_results(
11611 &mut self,
11612 frames: &[Frame],
11613 pushed_ids: &HashSet<&str>,
11614 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
11615 report: &mut SyncReport,
11616 last_final_push_result_id: Option<&str>,
11617 ) -> Result<(), String> {
11618 self.begin_observation("syncular_push_results")?;
11619 let previous_failed = self.failed_commits.clone();
11620 let previous_sync_needed = self.sync_needed;
11621 let conflict_count = self.conflicts.len();
11622 let rejection_count = self.rejections.len();
11623 let applied_count = report.applied.len();
11624 let rejected_count = report.rejected.len();
11625 let retryable_count = report.retryable.len();
11626 let was_dirty = self.overlay_dirty.snapshot();
11627 let mut removed = Vec::new();
11628 let mut batch = ChangeAccumulator::default();
11629 let persisted = (|| {
11630 for frame in frames {
11631 let Frame::PushResult {
11632 client_commit_id,
11633 status,
11634 results,
11635 commit_seq,
11636 } = frame
11637 else {
11638 return Err("unexpected acknowledgement frame".to_owned());
11639 };
11640 if !pushed_ids.contains(client_commit_id.as_str()) {
11641 continue;
11642 }
11643 let Some(index) = self
11644 .outbox
11645 .iter()
11646 .position(|commit| commit.client_commit_id == *client_commit_id)
11647 else {
11648 continue;
11649 };
11650 let status = *status;
11651 let rejection_details = rejection_details_by_commit.get(client_commit_id);
11652 let operations = self.outbox[index].ops.clone();
11653 match status {
11654 PushStatus::Applied | PushStatus::Cached => {
11655 let sequence =
11658 commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
11659 let affected: HashSet<_> = operations
11660 .iter()
11661 .map(|op| (op.table.as_str(), op.row_id.as_str()))
11662 .collect();
11663 let prior = self.acknowledged_operations(Some(&affected), None)?;
11664 for (op_index, operation) in operations.iter().enumerate() {
11665 let delivered: bool = self.conn.query_row("SELECT EXISTS(SELECT 1 FROM _syncular_row_deliveries WHERE tbl=?1 AND id=?2 AND commit_seq>=?3)", rusqlite::params![operation.table, operation.row_id, sequence], |row| row.get(0))
11666 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11667 if delivered {
11668 continue;
11669 }
11670 let table = self
11671 .schema
11672 .table(&operation.table)
11673 .ok_or_else(|| "sync.unknown_table".to_owned())?;
11674 let mut intended: Map<String, Value> = self
11675 .stored_row(table, &operation.row_id, true)
11676 .map(|(row, _)| {
11677 table
11678 .columns
11679 .iter()
11680 .zip(&row)
11681 .map(|(column, value)| {
11682 (column.name.clone(), column_value_to_json(value))
11683 })
11684 .collect()
11685 })
11686 .unwrap_or_default();
11687 let mut scope_image = intended.clone();
11688 for op in prior
11689 .iter()
11690 .chain(
11691 self.failed_commits
11692 .iter()
11693 .flat_map(|(commit, _)| &commit.ops),
11694 )
11695 .chain(
11696 self.outbox
11697 .iter()
11698 .take(index)
11699 .flat_map(|commit| &commit.ops),
11700 )
11701 .chain(operations.iter().take(op_index + 1))
11702 {
11703 if op.table != operation.table || op.row_id != operation.row_id {
11704 continue;
11705 }
11706 if let Some(values) = &op.values {
11707 intended.extend(values.clone());
11708 } else {
11709 scope_image = intended.clone();
11710 intended.clear();
11711 }
11712 }
11713 self.conn.execute("INSERT INTO _syncular_acked_rows(commit_id,idx,tbl,id,commit_seq,op_json,intent_json) VALUES (?1,?2,?3,?4,?5,?6,?7)",
11714 rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
11715 serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
11716 serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
11717 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11718 }
11719 let journal_results = results
11720 .iter()
11721 .map(|result| {
11722 let op_index = match result {
11723 OpResult::Applied { op_index }
11724 | OpResult::Conflict { op_index, .. }
11725 | OpResult::Error { op_index, .. } => *op_index,
11726 };
11727 CommitOperationOutcome::Applied { op_index }
11728 })
11729 .collect::<Vec<_>>();
11730 let outcome_status = if status == PushStatus::Applied {
11731 CommitOutcomeStatus::Applied
11732 } else {
11733 CommitOutcomeStatus::Cached
11734 };
11735 let persisted = self
11736 .persist_commit_outcome(
11737 client_commit_id,
11738 outcome_status,
11739 &journal_results,
11740 None,
11741 )
11742 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
11743 persisted?;
11744 for operation in &operations {
11745 self.overlay_dirty.table(&operation.table);
11746 }
11747 self.sync_needed = true;
11748 batch.status = true;
11749 batch.outcomes = true;
11750 }
11751 PushStatus::Rejected => {
11752 if results.iter().any(|result| {
11753 matches!(
11754 result,
11755 OpResult::Error {
11756 code,
11757 retryable: true,
11758 ..
11759 } if code == "sync.idempotency_cache_miss"
11760 )
11761 }) {
11762 report.retryable.push(client_commit_id.to_owned());
11765 continue;
11766 }
11767
11768 let mut journal_results = Vec::with_capacity(results.len());
11769 let mut conflicts = Vec::new();
11770 let mut rejections = Vec::new();
11771 for result in results {
11772 match result {
11773 OpResult::Applied { op_index } => {
11774 journal_results.push(CommitOperationOutcome::Applied {
11775 op_index: *op_index,
11776 });
11777 }
11778 OpResult::Conflict {
11779 op_index,
11780 code,
11781 message,
11782 server_version,
11783 server_row,
11784 conflict_columns: conflict_columns_bitmap,
11785 } => {
11786 let operation = operations
11787 .get(*op_index as usize)
11788 .map(CommitOperation::from);
11789 let (table, row_id) = operation
11790 .as_ref()
11791 .map(|op| (op.table.clone(), op.row_id.clone()))
11792 .unwrap_or_default();
11793 let server_row_json = self
11794 .schema
11795 .table(&table)
11796 .and_then(|t| {
11797 decode_row_bytes(t, server_row, &self.encryption)
11798 .ok()
11799 .map(|row| (t, row))
11800 })
11801 .map(|(t, row)| {
11802 let mut map = Map::new();
11803 for (i, column) in t.columns.iter().enumerate() {
11804 map.insert(
11805 column.name.clone(),
11806 column_value_to_json(
11807 row.get(i).unwrap_or(&None),
11808 ),
11809 );
11810 }
11811 map
11812 })
11813 .unwrap_or_default();
11814 let conflict_columns = self
11817 .schema
11818 .table(&table)
11819 .map(|t| {
11820 t.columns
11821 .iter()
11822 .enumerate()
11823 .filter(|(index, _)| {
11824 conflict_columns_bitmap
11825 .get(index / 8)
11826 .is_some_and(|byte| {
11827 byte & (1 << (index % 8)) != 0
11828 })
11829 })
11830 .map(|(_, column)| column.name.clone())
11831 .collect::<Vec<_>>()
11832 })
11833 .unwrap_or_default();
11834 let conflict = ConflictRecord {
11835 client_commit_id: client_commit_id.to_owned(),
11836 op_index: *op_index,
11837 table,
11838 row_id,
11839 code: code.clone(),
11840 message: message.clone(),
11841 server_version: *server_version,
11842 server_row: server_row_json,
11843 conflict_columns,
11844 operation,
11845 };
11846 journal_results.push(CommitOperationOutcome::Conflict {
11847 conflict: conflict.clone(),
11848 });
11849 conflicts.push(conflict);
11850 }
11851 OpResult::Error {
11852 op_index,
11853 code,
11854 message,
11855 retryable,
11856 } => {
11857 let rejection = RejectionRecord {
11858 client_commit_id: client_commit_id.to_owned(),
11859 op_index: *op_index,
11860 code: code.clone(),
11861 message: message.clone(),
11862 retryable: *retryable,
11863 details: rejection_details
11864 .and_then(|details| details.get(op_index))
11865 .cloned(),
11866 operation: operations
11867 .get(*op_index as usize)
11868 .map(CommitOperation::from),
11869 };
11870 journal_results.push(CommitOperationOutcome::Error {
11871 rejection: rejection.clone(),
11872 });
11873 rejections.push(rejection);
11874 }
11875 }
11876 }
11877 if self.retain_failed_commits {
11878 self.retain_failed_commit(client_commit_id, &operations)?;
11879 }
11880 let outcome_status = if conflicts.is_empty() {
11881 CommitOutcomeStatus::Rejected
11882 } else {
11883 CommitOutcomeStatus::Conflict
11884 };
11885 let persisted = self
11886 .persist_commit_outcome(
11887 client_commit_id,
11888 outcome_status,
11889 &journal_results,
11890 Some(&operations),
11891 )
11892 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
11893 persisted?;
11894 batch.conflicts = !conflicts.is_empty();
11895 batch.rejections = !rejections.is_empty();
11896 batch.status = true;
11897 batch.outcomes = true;
11898 self.conflicts.extend(conflicts);
11899 self.rejections.extend(rejections);
11900 for operation in &operations {
11901 self.overlay_dirty.table(&operation.table);
11902 }
11903 }
11904 }
11905 removed.push((index, self.outbox.remove(index)));
11906 if batch.status {
11907 for operation in &operations {
11908 if !self.record_row_scopes(
11909 &mut batch,
11910 &operation.table,
11911 &operation.row_id,
11912 false,
11913 ) {
11914 batch.table(&operation.table);
11915 }
11916 }
11917 }
11918 match status {
11919 PushStatus::Applied | PushStatus::Cached => {
11920 report.applied.push(client_commit_id.to_owned())
11921 }
11922 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
11923 }
11924 }
11925 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)) {
11926 self.prune_commit_outcomes()?;
11927 }
11928 self.finish_observation("syncular_push_results", batch)
11929 })();
11930 if let Err(error) = persisted {
11931 self.rollback_observation("syncular_push_results");
11932 self.failed_commits = previous_failed;
11933 self.sync_needed = previous_sync_needed;
11934 for (index, commit) in removed.into_iter().rev() {
11935 self.outbox.insert(index, commit);
11936 }
11937 self.conflicts.truncate(conflict_count);
11938 self.rejections.truncate(rejection_count);
11939 self.overlay_dirty.restore(was_dirty.clone());
11940 report.applied.truncate(applied_count);
11941 report.rejected.truncate(rejected_count);
11942 report.retryable.truncate(retryable_count);
11943 return Err(error);
11944 }
11945 if !previous_sync_needed && self.sync_needed {
11946 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11947 }
11948 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
11949 Ok(())
11950 }
11951
11952 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
11953 status != PushStatus::Rejected
11954 || !results.iter().any(|result| {
11955 matches!(
11956 result,
11957 OpResult::Error {
11958 code,
11959 retryable: true,
11960 ..
11961 } if code == "sync.idempotency_cache_miss"
11962 )
11963 })
11964 }
11965
11966 #[allow(clippy::too_many_arguments)]
11969 fn process_section(
11970 &mut self,
11971 transport: &mut dyn Transport,
11972 id: &str,
11973 status: SubStatus,
11974 reason_code: &str,
11975 effective_scopes: Vec<(String, Vec<String>)>,
11976 body: Vec<Frame>,
11977 sub_end: Option<(i64, Option<String>)>,
11978 meta: &RequestMeta,
11979 report: &mut SyncReport,
11980 ) -> Result<(), SectionError> {
11981 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
11982 return Ok(()); };
11984 if status != SubStatus::Active && sub_end.is_none() {
11985 return Err(SectionError::Abort(
11986 "sync.invalid_request".into(),
11987 "subscription section without SUB_END".into(),
11988 ));
11989 }
11990 match status {
11991 SubStatus::Revoked => {
11992 self.begin_observation("syncular_revocation")
11993 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
11994 let previous = self.subs[sub_index].clone();
11995 let was_dirty = self.overlay_dirty.snapshot();
11996 let previous_outbox = self.outbox.clone();
11997 let previous_failed = self.failed_commits.clone();
11998 let previous_rejections = self.rejections.clone();
11999 let previous_conflicts = self.conflicts.clone();
12000 let revoked_count = report.revoked.len();
12001 let failed_count = report.failed.len();
12002 let result =
12003 (|| {
12004 let mut batch = ChangeAccumulator::default();
12005 let registered = self.window_unit_by_sub_id(id);
12006 let (table, effective) = {
12008 let sub = &self.subs[sub_index];
12009 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
12010 };
12011 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
12012 SectionError::Abort(
12013 "sync.unknown_table".to_owned(),
12014 "revoked scope references an unknown table".to_owned(),
12015 )
12016 })?;
12017 let dropped = self
12018 .drop_failed_commits(|name, values| {
12019 name == table
12020 && !effective.is_empty()
12021 && effective.iter().all(|(variable, allowed)| {
12022 scoped_table
12023 .scope_column(variable)
12024 .and_then(|column| values.get(column))
12025 .and_then(Value::as_str)
12026 .is_some_and(|value| {
12027 allowed.iter().any(|allowed| allowed == value)
12028 })
12029 })
12030 })
12031 .map_err(|message| {
12032 SectionError::Abort("storage.failed".to_owned(), message)
12033 })?;
12034 for commit in dropped {
12035 batch.conflicts = true;
12036 batch.rejections = true;
12037 batch.outcomes = true;
12038 for operation in commit.ops {
12039 batch.table(&operation.table);
12040 }
12041 }
12042 let purged = self.purge_scope_rows(&table, &effective);
12043 match purged {
12044 Ok(()) => {
12045 self.record_scope_map(&mut batch, &table, &effective);
12046 let sub = &mut self.subs[sub_index];
12047 sub.state = SubState::Revoked;
12048 sub.reason_code = Some(if reason_code.is_empty() {
12049 "sync.scope_revoked".to_owned()
12050 } else {
12051 reason_code.to_owned()
12052 });
12053 report.revoked.push(id.to_owned());
12054 let doomed_effective = effective;
12055 let sub_table = table;
12056 self.persist_sub(&self.subs[sub_index].clone()).map_err(
12057 |message| SectionError::Abort("storage.failed".into(), message),
12058 )?;
12059 let dropped = self
12060 .drop_doomed_outbox(&sub_table, &doomed_effective)
12061 .map_err(|message| {
12062 SectionError::Abort("storage.failed".to_owned(), message)
12063 })?;
12064 if dropped {
12065 batch.status = true;
12066 batch.rejections = true;
12067 batch.outcomes = true;
12068 }
12069 self.delete_unreferenced_cached_blobs();
12072 }
12073 Err(()) => {
12074 let sub = &mut self.subs[sub_index];
12077 sub.state = SubState::Failed;
12078 sub.reason_code = Some("sync.scope_revoked".to_owned());
12079 report.failed.push(id.to_owned());
12080 self.persist_sub(&self.subs[sub_index].clone()).map_err(
12081 |message| SectionError::Abort("storage.failed".into(), message),
12082 )?;
12083 }
12084 }
12085 if let Some((base_key, unit)) = registered {
12086 batch.window(&base_key, &self.subs[sub_index].table, &unit);
12087 }
12088 self.rebuild_overlay_if_dirty();
12089 self.finish_observation("syncular_revocation", batch)
12090 .map_err(|message| {
12091 SectionError::Abort("storage.failed".to_owned(), message)
12092 })?;
12093 Ok(())
12094 })();
12095 if result.is_err() {
12096 self.rollback_observation("syncular_revocation");
12097 self.subs[sub_index] = previous;
12098 self.overlay_dirty.restore(was_dirty.clone());
12099 self.outbox = previous_outbox;
12100 self.failed_commits = previous_failed;
12101 self.rejections = previous_rejections;
12102 self.conflicts = previous_conflicts;
12103 report.revoked.truncate(revoked_count);
12104 report.failed.truncate(failed_count);
12105 }
12106 result
12107 }
12108 SubStatus::Reset => {
12109 self.begin_observation("syncular_reset")
12110 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
12111 let previous = self.subs[sub_index].clone();
12112 let was_dirty = self.overlay_dirty.snapshot();
12113 let reset_count = report.resets.len();
12114 let result = (|| {
12115 let mut batch = ChangeAccumulator::default();
12116 let registered = self.window_unit_by_sub_id(id);
12117 let sub = &mut self.subs[sub_index];
12120 sub.cursor = -1;
12121 sub.bootstrap_state = None;
12122 report.resets.push(id.to_owned());
12123 self.persist_sub(&self.subs[sub_index].clone())
12124 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12125 if let Some((base_key, unit)) = registered {
12126 batch.window(&base_key, &self.subs[sub_index].table, &unit);
12127 }
12128 self.finish_observation("syncular_reset", batch)
12129 .map_err(|message| {
12130 SectionError::Abort("storage.failed".to_owned(), message)
12131 })?;
12132 Ok(())
12133 })();
12134 if result.is_err() {
12135 self.rollback_observation("syncular_reset");
12136 self.subs[sub_index] = previous;
12137 self.overlay_dirty.restore(was_dirty.clone());
12138 report.resets.truncate(reset_count);
12139 }
12140 result
12141 }
12142 SubStatus::Active => {
12143 let fresh = meta
12144 .fresh
12145 .iter()
12146 .find(|(fid, _)| fid == id)
12147 .map(|(_, fresh)| *fresh)
12148 .unwrap_or(false);
12149 let previous = self.subs[sub_index].clone();
12150 self.subs[sub_index].effective = Some(effective_scopes);
12151 let outcome = self
12152 .apply_section_body(transport, sub_index, body, fresh, meta, report)
12153 .and_then(|()| {
12154 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
12155 SectionError::Abort(
12156 "sync.invalid_request".into(),
12157 "subscription section without SUB_END".into(),
12158 )
12159 })?;
12160 self.apply_sub_end(sub_index, cursor, bootstrap_state)
12161 .map_err(|message| {
12162 SectionError::Abort("storage.failed".into(), message)
12163 })
12164 });
12165 match outcome {
12166 Ok(()) => {
12167 if self.subs[sub_index].bootstrap_state.is_some() {
12168 report.bootstrapping.push(id.to_owned());
12169 }
12170 Ok(())
12171 }
12172 Err(SectionError::FailClosed) => {
12173 self.subs[sub_index] = previous.clone();
12174 self.begin_observation("syncular_section_failure")
12175 .map_err(|message| {
12176 SectionError::Abort("storage.failed".into(), message)
12177 })?;
12178 let persisted = (|| {
12179 let mut batch = ChangeAccumulator::default();
12180 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
12181 batch.window(&base_key, &self.subs[sub_index].table, &unit);
12182 }
12183 self.subs[sub_index].state = SubState::Failed;
12184 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
12185 self.persist_sub(&self.subs[sub_index])?;
12186 self.finish_observation("syncular_section_failure", batch)
12187 })();
12188 if let Err(message) = persisted {
12189 self.rollback_observation("syncular_section_failure");
12190 self.subs[sub_index] = previous;
12191 return Err(SectionError::Abort("storage.failed".into(), message));
12192 }
12193 report.failed.push(id.to_owned());
12194 Ok(())
12195 }
12196 Err(error) => {
12197 self.subs[sub_index] = previous;
12200 Err(error)
12201 }
12202 }
12203 }
12204 }
12205 }
12206
12207 fn apply_sub_end(
12208 &mut self,
12209 sub_index: usize,
12210 cursor: i64,
12211 bootstrap_state: Option<String>,
12212 ) -> Result<(), String> {
12213 #[cfg(feature = "bench-internals")]
12214 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
12215 let previous = self.subs[sub_index].clone();
12216 self.begin_observation("syncular_sub_end")?;
12217 let persisted = (|| {
12218 let mut batch = ChangeAccumulator::default();
12219 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
12220 && cursor >= 0
12221 && bootstrap_state.is_none();
12222 if completed {
12223 let table = self
12224 .schema
12225 .table(&previous.table)
12226 .ok_or_else(|| "sync.unknown_table".to_owned())?;
12227 let effective = previous.effective.as_deref().unwrap_or(&[]);
12228 let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
12229 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12230 let rows = statement
12231 .query_map(rusqlite::params![previous.table, cursor], |row| {
12232 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
12233 })
12234 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12235 let mut covered = HashSet::new();
12236 for row in rows {
12237 let (id, intent) =
12238 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12239 let values: Map<String, Value> = serde_json::from_str(&intent)
12240 .map_err(|_| "sync.local_corrupt".to_owned())?;
12241 if !effective.is_empty()
12242 && effective.iter().all(|(variable, allowed)| {
12243 table
12244 .scope_column(variable)
12245 .and_then(|column| values.get(column))
12246 .is_some_and(|value| {
12247 allowed.contains(
12248 &value
12249 .as_str()
12250 .map_or_else(|| value.to_string(), str::to_owned),
12251 )
12252 })
12253 })
12254 {
12255 covered.insert(id);
12256 }
12257 }
12258 drop(statement);
12259 for id in covered {
12260 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
12261 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12262 self.overlay_dirty.table(&previous.table);
12263 batch.table(&previous.table);
12264 }
12265 self.rebuild_overlay_if_dirty();
12266 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
12267 batch.window(&base_key, &previous.table, &unit);
12268 }
12269 }
12270 let sub = &mut self.subs[sub_index];
12271 sub.cursor = cursor;
12272 sub.bootstrap_state = bootstrap_state;
12273 sub.synced_once = true;
12274 self.persist_sub(&self.subs[sub_index])?;
12275 self.finish_observation("syncular_sub_end", batch)
12276 })();
12277 if persisted.is_err() {
12278 self.rollback_observation("syncular_sub_end");
12279 self.subs[sub_index] = previous;
12280 }
12281 persisted
12282 }
12283
12284 fn apply_section_body(
12285 &mut self,
12286 transport: &mut dyn Transport,
12287 sub_index: usize,
12288 body: Vec<Frame>,
12289 fresh: bool,
12290 meta: &RequestMeta,
12291 report: &mut SyncReport,
12292 ) -> Result<(), SectionError> {
12293 let mut saw_segment = false;
12294 for frame in body {
12295 match frame {
12296 Frame::Commit {
12297 tables,
12298 changes,
12299 commit_seq,
12300 ..
12301 } => {
12302 self.apply_commit_frame(&tables, &changes, Some(commit_seq))
12303 .map_err(|(c, m)| SectionError::Abort(c, m))?;
12304 report.commits_applied += 1;
12305 }
12306 Frame::SegmentInline { payload } => {
12307 let segment = decode_rows_segment(&payload)
12308 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
12309 self.progress.update(|p| {
12310 p.phase = ProgressPhase::Import;
12311 p.subscription_id = Some(self.subs[sub_index].id.clone());
12312 p.table = Some(self.subs[sub_index].table.clone());
12313 p.segment_id = None;
12314 p.bytes_received = payload.len() as u64;
12315 p.bytes_total = Some(payload.len() as u64);
12316 p.rows_processed = 0;
12317 p.rows_total =
12318 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
12319 });
12320 let first = !saw_segment;
12321 saw_segment = true;
12322 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
12323 report.segment_rows_applied += applied;
12324 }
12325 Frame::SegmentRef {
12326 segment_id,
12327 byte_length,
12328 media_type,
12329 table,
12330 row_count,
12331 as_of_commit_seq,
12332 scope_digest,
12333 row_cursor,
12334 next_row_cursor,
12335 url,
12336 url_expires_at_ms,
12337 ..
12338 } => {
12339 let advertised = match media_type {
12342 MediaType::Rows => {
12343 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
12344 || meta.accept & ACCEPT_INLINE_ROWS != 0
12345 }
12346 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
12347 };
12348 if !advertised {
12349 return Err(SectionError::Abort(
12350 "sync.invalid_request".to_owned(),
12351 format!(
12352 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
12353 media_type.name()
12354 ),
12355 ));
12356 }
12357 self.progress.update(|p| {
12358 p.phase = ProgressPhase::Download;
12359 p.subscription_id = Some(self.subs[sub_index].id.clone());
12360 p.table = Some(table.clone());
12361 p.segment_id = Some(segment_id.clone());
12362 p.bytes_received = 0;
12363 p.bytes_total = u64::try_from(byte_length).ok();
12364 p.rows_processed = 0;
12365 p.rows_total = u64::try_from(row_count).ok();
12366 });
12367 let observer = self.progress.clone();
12368 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
12369 let bytes = if let Some(url) = url {
12370 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
12375 return Err(SectionError::Abort(
12376 "sync.invalid_request".to_owned(),
12377 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
12378 .to_owned(),
12379 ));
12380 }
12381 if url_expires_at_ms.is_some_and(|exp| {
12383 exp <= transport
12384 .segment_fetch_started_at(&url)
12385 .unwrap_or_else(|| self.clock_now_ms())
12386 }) {
12387 return Err(SectionError::Abort(
12388 "sync.segment_expired".to_owned(),
12389 format!(
12390 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
12391 ),
12392 ));
12393 }
12394 transport
12395 .fetch_url(&url, &mut on_progress)
12396 .map_err(|e| SectionError::Abort(e.code, e.message))?
12397 } else {
12398 let requested_scopes_json =
12399 canonical_scope_json(&self.subs[sub_index].requested);
12400 transport
12401 .download_segment(
12402 &SegmentRequest {
12403 segment_id: segment_id.clone(),
12404 table,
12405 requested_scopes_json,
12406 },
12407 &mut on_progress,
12408 )
12409 .map_err(|e| SectionError::Abort(e.code, e.message))?
12410 };
12411 on_progress(bytes.len() as u64);
12413 let digest = Sha256::digest(&bytes);
12414 let expected = segment_id
12415 .strip_prefix("sha256:")
12416 .unwrap_or(segment_id.as_str());
12417 if bytes_to_hex(&digest) != expected {
12418 self.schedule_background_retry();
12420 return Err(SectionError::Abort(
12421 "sync.invalid_request".to_owned(),
12422 "segment bytes do not match the content address (§5.1)".to_owned(),
12423 ));
12424 }
12425 self.progress.update(|p| p.phase = ProgressPhase::Import);
12426 if media_type == MediaType::Sqlite {
12427 if row_cursor.is_some() || next_row_cursor.is_some() {
12430 return Err(SectionError::Abort(
12431 "sync.invalid_request".to_owned(),
12432 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
12433 .to_owned(),
12434 ));
12435 }
12436 let first = !saw_segment;
12437 saw_segment = true;
12438 let applied = self.apply_sqlite_segment(
12439 sub_index,
12440 &bytes,
12441 fresh && first,
12442 row_count,
12443 as_of_commit_seq,
12444 &scope_digest,
12445 )?;
12446 report.segment_rows_applied += applied;
12447 } else {
12448 let segment = decode_rows_segment(&bytes).map_err(|e| {
12449 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
12450 })?;
12451 let first = row_cursor.is_none();
12452 saw_segment = true;
12453 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
12454 report.segment_rows_applied += applied;
12455 }
12456 }
12457 Frame::Unknown { .. } => {}
12458 Frame::Error { code, message, .. } => {
12459 return Err(SectionError::Abort(code, message))
12460 }
12461 _ => {
12462 return Err(SectionError::Abort(
12463 "sync.invalid_request".to_owned(),
12464 "unexpected frame inside a subscription section".to_owned(),
12465 ));
12466 }
12467 }
12468 }
12469 Ok(())
12470 }
12471
12472 fn apply_commit_frame(
12474 &mut self,
12475 tables: &[String],
12476 changes: &[ssp2::model::Change],
12477 commit_seq: Option<i64>,
12478 ) -> Result<(), (String, String)> {
12479 #[cfg(feature = "bench-internals")]
12480 let _phase = self.benchmark_phases.start(Phase::CommitApply);
12481 let was_dirty = self.overlay_dirty.snapshot();
12482 self.begin_observation("syncular_commit")
12483 .map_err(|message| ("storage.failed".into(), message))?;
12484 let applied = (|| {
12485 let mut batch = ChangeAccumulator::default();
12486 self.record_commit_changes(&mut batch, tables, changes);
12487 if let Some(sequence) = commit_seq {
12488 for change in changes {
12489 let table = tables.get(change.table_index as usize).ok_or_else(|| {
12490 (
12491 "sync.invalid_request".to_owned(),
12492 "change tableIndex out of range".to_owned(),
12493 )
12494 })?;
12495 if self
12496 .outbox
12497 .iter()
12498 .flat_map(|commit| &commit.ops)
12499 .any(|op| op.table == *table && op.row_id == change.row_id)
12500 {
12501 self.conn.execute("INSERT INTO _syncular_row_deliveries(tbl,id,commit_seq) VALUES (?1,?2,?3) ON CONFLICT(tbl,id) DO UPDATE SET commit_seq=MAX(commit_seq,excluded.commit_seq)", rusqlite::params![table, change.row_id, sequence])
12502 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
12503 }
12504 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![table, change.row_id, sequence])
12505 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
12506 }
12507 }
12508 self.apply_commit_changes(tables, changes)?;
12509 self.rebuild_overlay_if_dirty();
12510 self.finish_observation("syncular_commit", batch)
12511 .map_err(|message| ("storage.failed".into(), message))
12512 })();
12513 if applied.is_err() {
12514 self.rollback_observation("syncular_commit");
12515 self.overlay_dirty.restore(was_dirty.clone());
12516 }
12517 applied
12518 }
12519
12520 fn apply_commit_changes(
12521 &mut self,
12522 tables: &[String],
12523 changes: &[ssp2::model::Change],
12524 ) -> Result<(), (String, String)> {
12525 let mirror_visible = !self.overlay_dirty.get()
12530 && self.failed_commits.is_empty()
12531 && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
12532 || changes.iter().all(|change| {
12533 tables
12534 .get(change.table_index as usize)
12535 .and_then(|name| self.schema.table(name))
12536 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
12537 }));
12538 for change in changes {
12539 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
12540 (
12541 "sync.invalid_request".to_owned(),
12542 "change tableIndex out of range".to_owned(),
12543 )
12544 })?;
12545 let table = self.schema.table(table_name).ok_or_else(|| {
12546 (
12547 "sync.schema_mismatch".to_owned(),
12548 format!("change targets unknown table {table_name:?}"),
12549 )
12550 })?;
12551 match change.op {
12552 Op::Upsert => {
12553 let payload = change.row.as_ref().ok_or_else(|| {
12554 (
12555 "sync.invalid_request".to_owned(),
12556 "upsert change without row payload".to_owned(),
12557 )
12558 })?;
12559 let row = {
12561 #[cfg(feature = "bench-internals")]
12562 let _phase = self.benchmark_phases.start(Phase::RowDecode);
12563 decode_row_bytes(table, payload, &self.encryption)
12564 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
12565 };
12566 let version = change.row_version.unwrap_or(0);
12567 let table_name = table.name.clone();
12568 self.write_base_row(&table_name, &row, version)
12569 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
12570 if mirror_visible {
12571 self.write_row(&visible_table(&table_name), &table_name, &row, version)
12572 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
12573 }
12574 }
12575 Op::Delete => {
12576 self.delete_base_row(table_name, &change.row_id)
12577 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
12578 if mirror_visible {
12579 let sql = format!(
12580 "DELETE FROM {} WHERE {}",
12581 visible_table(table_name),
12582 row_id_predicate(table)
12583 );
12584 self.conn
12585 .prepare_cached(&sql)
12586 .and_then(|mut statement| {
12587 statement.execute(rusqlite::params![change.row_id])
12588 })
12589 .map_err(|error| {
12590 ("sync.invalid_request".to_owned(), error.to_string())
12591 })?;
12592 }
12593 }
12594 }
12595 }
12596 if mirror_visible {
12597 {
12598 let changed_rows: HashSet<(&str, &str)> = changes
12599 .iter()
12600 .map(|change| {
12601 (
12602 tables[change.table_index as usize].as_str(),
12603 change.row_id.as_str(),
12604 )
12605 })
12606 .collect();
12607 let acknowledged = self
12608 .acknowledged_operations(Some(&changed_rows), None)
12609 .map_err(|error| ("storage.failed".to_owned(), error))?;
12610 self.apply_outbox_ops(&acknowledged);
12611 self.apply_outbox_ops(
12612 self.outbox
12613 .iter()
12614 .flat_map(|commit| &commit.ops)
12615 .filter(|op| {
12616 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
12617 }),
12618 );
12619 }
12620 self.overlay_dirty.set(false);
12621 }
12622 Ok(())
12623 }
12624
12625 fn apply_segment(
12630 &mut self,
12631 sub_index: usize,
12632 segment: &RowsSegment,
12633 first_fresh_page: bool,
12634 ) -> Result<u32, SectionError> {
12635 let (sub_table, effective) = {
12636 let sub = &self.subs[sub_index];
12637 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
12638 };
12639 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
12640 SectionError::Abort(
12641 "sync.schema_mismatch".to_owned(),
12642 format!("subscription table {sub_table:?} is not in the client schema"),
12643 )
12644 })?;
12645 let matches = segment.table == table.name
12650 && segment.schema_version == self.schema.version
12651 && segment.columns.len() == table.wire_columns.len()
12652 && segment
12653 .columns
12654 .iter()
12655 .zip(table.wire_columns.iter())
12656 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
12657 if !matches {
12658 return Err(SectionError::Abort(
12659 "sync.schema_mismatch".to_owned(),
12660 "segment column table does not match the generated schema (§5.2)".to_owned(),
12661 ));
12662 }
12663 let mut applied = 0u32;
12664 let empty = Vec::new();
12665 let blocks = if segment.blocks.is_empty() {
12666 std::slice::from_ref(&empty)
12667 } else {
12668 &segment.blocks
12669 };
12670 for (index, block) in blocks.iter().enumerate() {
12671 let clear = first_fresh_page && index == 0;
12672 if !clear && block.is_empty() {
12673 continue;
12674 }
12675 let was_dirty = self.overlay_dirty.snapshot();
12676 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
12677 && self
12678 .outbox
12679 .iter()
12680 .flat_map(|commit| &commit.ops)
12681 .any(|op| op.table == table.name);
12682 let mirror_visible = !reconcile_pending
12683 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
12684 self.begin_observation("syncular_segment_block")
12685 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12686 let outcome = (|| {
12687 self.rebuild_overlay_if_dirty();
12688 let mut changed_keys = Vec::new();
12689 let mut batch = ChangeAccumulator::default();
12690 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
12691 batch.table(&table.name);
12692 }
12693 if clear {
12694 self.purge_scope_rows(&table.name, &effective)
12695 .map_err(|()| SectionError::FailClosed)?;
12696 }
12697 for (row_index, row) in block.iter().enumerate() {
12698 let decrypted;
12699 let values = if table.has_encrypted_columns() {
12700 let mut values = row.values.clone();
12701 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
12702 .map_err(|message| {
12703 SectionError::Abort("client.decrypt_failed".into(), message)
12704 })?;
12705 decrypted = values;
12706 &decrypted
12707 } else {
12708 &row.values
12709 };
12710 self.write_base_row(&table.name, values, row.server_version)
12711 .map_err(|message| {
12712 SectionError::Abort("sync.invalid_request".into(), message)
12713 })?;
12714 if reconcile_pending {
12715 changed_keys.push(
12716 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
12717 |message| SectionError::Abort("storage.failed".into(), message),
12718 )?,
12719 );
12720 }
12721 if mirror_visible {
12722 self.write_row(
12723 &visible_table(&table.name),
12724 &table.name,
12725 values,
12726 row.server_version,
12727 )
12728 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12729 }
12730 let processed = u64::from(applied) + row_index as u64 + 1;
12731 if processed.is_multiple_of(1024) {
12732 self.progress.update(|p| p.rows_processed = processed);
12733 }
12734 }
12735 if reconcile_pending {
12736 self.reconcile_imported_rows(&table, &mut changed_keys)
12737 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12738 }
12739 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
12740 self.apply_outbox_ops(
12741 self.outbox
12742 .iter()
12743 .flat_map(|commit| &commit.ops)
12744 .filter(|op| op.table == table.name),
12745 );
12746 self.overlay_dirty.set(false);
12747 } else {
12748 self.rebuild_overlay_if_dirty();
12749 }
12750 self.finish_observation("syncular_segment_block", batch)
12751 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
12752 })();
12753 if let Err(error) = outcome {
12754 self.rollback_observation("syncular_segment_block");
12755 self.overlay_dirty.restore(was_dirty.clone());
12756 return Err(error);
12757 }
12758 applied += block.len() as u32;
12759 self.progress
12760 .update(|p| p.rows_processed = u64::from(applied));
12761 std::thread::yield_now();
12762 }
12763 self.progress
12764 .update(|p| p.rows_processed = u64::from(applied));
12765 Ok(applied)
12766 }
12767
12768 fn apply_sqlite_segment(
12776 &mut self,
12777 sub_index: usize,
12778 bytes: &[u8],
12779 first_fresh_page: bool,
12780 row_count: i64,
12781 as_of_commit_seq: i64,
12782 scope_digest: &str,
12783 ) -> Result<u32, SectionError> {
12784 let invalid = |detail: &str| {
12785 SectionError::Abort(
12786 "sync.invalid_request".to_owned(),
12787 format!("sqlite segment rejected: {detail} (§5.3)"),
12788 )
12789 };
12790 let (sub_table, effective) = {
12791 let sub = &self.subs[sub_index];
12792 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
12793 };
12794 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
12795 SectionError::Abort(
12796 "sync.schema_mismatch".to_owned(),
12797 format!("subscription table {sub_table:?} is not in the client schema"),
12798 )
12799 })?;
12800
12801 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
12802 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
12803 let img = match rusqlite::Connection::open_with_flags(
12804 &path,
12805 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
12806 ) {
12807 Ok(conn) => conn,
12808 Err(_) => {
12809 let _ = std::fs::remove_file(&path);
12810 return Err(invalid("bytes do not open as a SQLite database"));
12811 }
12812 };
12813 let outcome = self.apply_sqlite_image(
12814 &img,
12815 &table,
12816 first_fresh_page,
12817 &effective,
12818 row_count,
12819 as_of_commit_seq,
12820 scope_digest,
12821 );
12822 drop(img);
12823 let _ = std::fs::remove_file(&path);
12824 outcome
12825 }
12826
12827 #[allow(clippy::too_many_arguments)]
12828 fn apply_sqlite_image(
12829 &mut self,
12830 img: &rusqlite::Connection,
12831 table: &crate::schema::TableSchema,
12832 first_fresh_page: bool,
12833 effective: &[(String, Vec<String>)],
12834 row_count: i64,
12835 as_of_commit_seq: i64,
12836 scope_digest: &str,
12837 ) -> Result<u32, SectionError> {
12838 let invalid = |detail: String| {
12839 SectionError::Abort(
12840 "sync.invalid_request".to_owned(),
12841 format!("sqlite segment rejected: {detail} (§5.3)"),
12842 )
12843 };
12844
12845 type MetaRow = (i64, String, i64, i64, String, i64, i64);
12848 let meta: MetaRow = img
12849 .query_row(
12850 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
12851 \"scopeDigest\", \"rowCount\",
12852 (SELECT count(*) FROM _syncular_segment)
12853 FROM _syncular_segment",
12854 [],
12855 |row| {
12856 Ok((
12857 row.get(0)?,
12858 row.get(1)?,
12859 row.get(2)?,
12860 row.get(3)?,
12861 row.get(4)?,
12862 row.get(5)?,
12863 row.get(6)?,
12864 ))
12865 },
12866 )
12867 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
12868 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
12869 if meta_count != 1 {
12870 return Err(invalid(format!(
12871 "_syncular_segment must contain exactly one row, found {meta_count}"
12872 )));
12873 }
12874 if format != 1 {
12875 return Err(invalid(format!("format {format}")));
12876 }
12877 if meta_table != table.name {
12878 return Err(invalid(format!("image table {meta_table:?}")));
12879 }
12880 if schema_version != i64::from(self.schema.version) {
12881 return Err(invalid(format!("schemaVersion {schema_version}")));
12882 }
12883 if pin != as_of_commit_seq {
12884 return Err(invalid(format!("asOfCommitSeq {pin}")));
12885 }
12886 if digest != scope_digest {
12887 return Err(invalid("scopeDigest mismatch".to_owned()));
12888 }
12889 if meta_rows != row_count {
12890 return Err(invalid(format!("rowCount {meta_rows}")));
12891 }
12892
12893 let mut names: Vec<String> = Vec::new();
12895 let mut primary_keys: Vec<String> = Vec::new();
12896 {
12897 let mut stmt = img
12898 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
12899 .map_err(|_| invalid("image data table missing".to_owned()))?;
12900 let mut rows = stmt
12901 .query([])
12902 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
12903 while let Some(row) = rows
12904 .next()
12905 .map_err(|_| invalid("image data table unreadable".to_owned()))?
12906 {
12907 if row
12908 .get::<_, i64>(5)
12909 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
12910 > 0
12911 {
12912 primary_keys.push(row.get(1).map_err(|error| {
12913 invalid(Self::sqlite_failure(&self.storage_failure, error))
12914 })?);
12915 }
12916 names.push(
12917 row.get::<_, String>(1)
12918 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
12919 );
12920 }
12921 }
12922 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
12923 expected.push("_syncular_version");
12924 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
12925 return Err(SectionError::Abort(
12926 "sync.schema_mismatch".to_owned(),
12927 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
12928 ));
12929 }
12930
12931 if primary_keys != vec![table.primary_key.clone()] {
12932 return Err(invalid(
12933 "image primary key does not match generated schema".into(),
12934 ));
12935 }
12936 let actual_count: i64 = img
12937 .query_row(
12938 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
12939 [],
12940 |row| row.get(0),
12941 )
12942 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12943 if actual_count != row_count {
12944 return Err(invalid("image row count does not match descriptor".into()));
12945 }
12946 let insert = self.insert_row_sql(&base_table(&table.name), table);
12947 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
12948 let column_list = names
12949 .iter()
12950 .map(|n| quote_ident(n))
12951 .collect::<Vec<_>>()
12952 .join(", ");
12953 let mut stmt = img
12954 .prepare(&format!(
12955 "SELECT {column_list} FROM {} ORDER BY {}",
12956 quote_ident(&table.name),
12957 quote_ident(&table.primary_key)
12958 ))
12959 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12960 let mut rows = stmt
12961 .query([])
12962 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12963 let mut applied = 0u32;
12964 loop {
12965 let clear = applied == 0 && first_fresh_page;
12966 let was_dirty = self.overlay_dirty.snapshot();
12967 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
12968 && self
12969 .outbox
12970 .iter()
12971 .flat_map(|commit| &commit.ops)
12972 .any(|op| op.table == table.name);
12973 let mirror_visible = !reconcile_pending
12974 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
12975 self.begin_observation("syncular_image_chunk")
12976 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12977 let result = (|| {
12978 self.rebuild_overlay_if_dirty();
12979 let mut changed_keys = Vec::new();
12980 let mut batch = ChangeAccumulator::default();
12981 if clear {
12982 if self.scoped_rows_exist(&table.name, effective) {
12983 batch.table(&table.name);
12984 }
12985 self.purge_scope_rows(&table.name, effective)
12986 .map_err(|()| SectionError::FailClosed)?;
12987 }
12988 let mut processed = 0u32;
12989 {
12990 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
12991 invalid(Self::sqlite_failure(&self.storage_failure, error))
12992 })?;
12993 let mut mirror = if mirror_visible {
12994 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
12995 invalid(Self::sqlite_failure(&self.storage_failure, error))
12996 })?)
12997 } else {
12998 None
12999 };
13000 for _ in 0..1024 {
13001 let Some(row) = rows.next().map_err(|error| {
13002 invalid(Self::sqlite_failure(&self.storage_failure, error))
13003 })?
13004 else {
13005 break;
13006 };
13007 for (i, column) in table.columns.iter().enumerate() {
13008 let cell = row.get_ref(i).map_err(|error| {
13009 invalid(Self::sqlite_failure(&self.storage_failure, error))
13010 })?;
13011 let param = image_cell_param(column, cell).map_err(invalid)?;
13012 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
13013 invalid(Self::sqlite_failure(&self.storage_failure, error))
13014 })?;
13015 if let Some(mirror) = &mut mirror {
13016 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
13017 invalid(Self::sqlite_failure(&self.storage_failure, error))
13018 })?;
13019 }
13020 }
13021 let version_index = table.columns.len();
13022 let version: i64 = row.get(version_index).map_err(|error| {
13023 invalid(Self::sqlite_failure(&self.storage_failure, error))
13024 })?;
13025 if version < 1 {
13026 return Err(invalid("image row version must be positive".into()));
13027 }
13028 ins.raw_bind_parameter(version_index + 1, version)
13029 .map_err(|error| {
13030 invalid(Self::sqlite_failure(&self.storage_failure, error))
13031 })?;
13032 ins.raw_execute().map_err(|error| {
13033 invalid(Self::sqlite_failure(&self.storage_failure, error))
13034 })?;
13035 if let Some(mirror) = &mut mirror {
13036 mirror
13037 .raw_bind_parameter(version_index + 1, version)
13038 .map_err(|error| {
13039 invalid(Self::sqlite_failure(&self.storage_failure, error))
13040 })?;
13041 mirror.raw_execute().map_err(|error| {
13042 invalid(Self::sqlite_failure(&self.storage_failure, error))
13043 })?;
13044 }
13045 if reconcile_pending {
13046 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
13047 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
13048 )?);
13049 }
13050 processed += 1;
13051 }
13052 }
13053 if processed > 0 {
13054 batch.table(&table.name);
13055 self.overlay_dirty.table(&table.name);
13056 }
13057 if reconcile_pending {
13058 self.reconcile_imported_rows(table, &mut changed_keys)
13059 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
13060 }
13061 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
13062 self.apply_outbox_ops(
13063 self.outbox
13064 .iter()
13065 .flat_map(|commit| &commit.ops)
13066 .filter(|op| op.table == table.name),
13067 );
13068 self.overlay_dirty.set(false);
13069 } else {
13070 self.rebuild_overlay_if_dirty();
13071 }
13072 self.finish_observation("syncular_image_chunk", batch)
13073 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
13074 Ok(processed)
13075 })();
13076 let processed = match result {
13077 Ok(processed) => processed,
13078 Err(error) => {
13079 self.rollback_observation("syncular_image_chunk");
13080 self.overlay_dirty.restore(was_dirty.clone());
13081 return Err(error);
13082 }
13083 };
13084 applied += processed;
13085 self.progress
13086 .update(|p| p.rows_processed = u64::from(applied));
13087 std::thread::yield_now();
13088 if i64::from(applied) == actual_count {
13089 break;
13090 }
13091 }
13092 Ok(applied)
13093 }
13094
13095 fn reconcile_imported_rows(
13099 &self,
13100 table: &TableSchema,
13101 keys: &mut Vec<SqlValue>,
13102 ) -> Result<(), String> {
13103 let visible = visible_table(&table.name);
13104 let base = base_table(&table.name);
13105 let pk = quote_ident(&table.primary_key);
13106 let predicate = row_id_predicate(table);
13107 let mut lookup = self.conn.prepare_cached(&format!(
13108 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
13109 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13110 let mut deleted = HashSet::new();
13111 for op in self
13112 .outbox
13113 .iter()
13114 .flat_map(|commit| &commit.ops)
13115 .filter(|op| op.table == table.name)
13116 {
13117 if op.upsert {
13118 if let Some(values) = &op.values {
13119 let key = json_to_column_value(
13120 &table.columns[table.pk_index],
13121 values.get(&table.primary_key),
13122 )?;
13123 keys.push(owned_sql_value(RowParam::Cell(&key))?);
13124 }
13125 } else if deleted.insert(&op.row_id) {
13126 keys.extend(
13127 lookup
13128 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
13129 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13130 .collect::<Result<Vec<_>, _>>()
13131 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
13132 );
13133 }
13134 }
13135 for chunk in keys.chunks(400) {
13138 let holes = vec!["?"; chunk.len()].join(",");
13139 self.conn
13140 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
13141 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
13142 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13143 }
13144 let updates = table
13145 .columns
13146 .iter()
13147 .map(|column| column.name.as_str())
13148 .chain(std::iter::once("_syncular_version"))
13149 .filter(|name| *name != table.primary_key)
13150 .map(|name| {
13151 let name = quote_ident(name);
13152 format!("{name}=excluded.{name}")
13153 })
13154 .collect::<Vec<_>>()
13155 .join(",");
13156 for chunk in keys.chunks(400) {
13157 let holes = vec!["?"; chunk.len()].join(",");
13158 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
13159 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
13160 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13161 }
13162 Ok(())
13163 }
13164
13165 fn compile_local_data_purge(
13168 &self,
13169 input: &LocalDataPurgeInput,
13170 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
13171 let invalid = |message: String| format!("sync.invalid_request: {message}");
13172 if input.purge_id.is_empty()
13173 || input.purge_id.len() > 128
13174 || !is_local_operation_code_like(&input.purge_id)
13175 {
13176 return Err(invalid(
13177 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
13178 ));
13179 }
13180 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
13181 return Err(invalid(format!(
13182 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
13183 )));
13184 }
13185
13186 let mut deduplicated: BTreeMap<
13187 String,
13188 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
13189 > = BTreeMap::new();
13190 for target in &input.targets {
13191 let table = self.schema.table(&target.table).ok_or_else(|| {
13192 invalid(format!(
13193 "local purge names unknown table {:?}",
13194 target.table
13195 ))
13196 })?;
13197 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
13198 return Err(invalid(format!(
13199 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
13200 target.table
13201 )));
13202 }
13203 let mut selectors = Vec::with_capacity(target.selectors.len());
13204 let mut canonical_selectors = BTreeMap::new();
13205 for (column_name, raw_values) in &target.selectors {
13206 let Some((column_index, column)) = table
13207 .columns
13208 .iter()
13209 .enumerate()
13210 .find(|(_, column)| column.name == *column_name)
13211 else {
13212 return Err(invalid(format!(
13213 "local purge target {:?} names unknown column {:?}",
13214 target.table, column_name
13215 )));
13216 };
13217 let encrypted = table
13218 .encrypted_columns
13219 .iter()
13220 .any(|candidate| candidate.index == column_index);
13221 if column.ty != ColumnType::String || encrypted {
13222 return Err(invalid(format!(
13223 "local purge selector {:?}.{:?} must be a plaintext string column",
13224 target.table, column_name
13225 )));
13226 }
13227 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
13228 return Err(invalid(format!(
13229 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
13230 target.table, column_name
13231 )));
13232 }
13233 let mut values = raw_values.clone();
13234 values.sort();
13235 values.dedup();
13236 if values.iter().any(|value| {
13237 value.is_empty()
13238 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
13239 || !is_local_operation_code_like(value)
13240 }) {
13241 return Err(invalid(format!(
13242 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
13243 )));
13244 }
13245 selectors.push((column_name.clone(), values.clone()));
13246 canonical_selectors.insert(column_name.clone(), values);
13247 }
13248 let canonical = LocalDataPurgeTarget {
13249 table: target.table.clone(),
13250 selectors: canonical_selectors,
13251 };
13252 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
13253 deduplicated.insert(
13254 key,
13255 (
13256 CompiledLocalDataPurgeTarget {
13257 table: target.table.clone(),
13258 selectors,
13259 },
13260 canonical,
13261 ),
13262 );
13263 }
13264 let targets = deduplicated
13265 .values()
13266 .map(|(compiled, _)| compiled.clone())
13267 .collect::<Vec<_>>();
13268 let canonical = deduplicated
13269 .values()
13270 .map(|(_, target)| target.clone())
13271 .collect::<Vec<_>>();
13272 let canonical_plan =
13273 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
13274 Ok((targets, canonical_plan))
13275 }
13276
13277 fn local_purge_base_row_ids(
13278 &self,
13279 targets: &[CompiledLocalDataPurgeTarget],
13280 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
13281 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
13282 for target in targets {
13283 let table = self
13284 .schema
13285 .table(&target.table)
13286 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
13287 let mut clauses = Vec::with_capacity(target.selectors.len());
13288 let mut params = Vec::new();
13289 for (column, values) in &target.selectors {
13290 clauses.push(format!(
13291 "{} IN ({})",
13292 quote_ident(column),
13293 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
13294 ));
13295 params.extend(values.iter().cloned().map(SqlValue::Text));
13296 }
13297 let sql = format!(
13298 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
13299 quote_ident(&table.primary_key),
13300 base_table(&target.table),
13301 clauses.join(" AND ")
13302 );
13303 let mut statement = self
13304 .conn
13305 .prepare(&sql)
13306 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13307 let rows = statement
13308 .query_map(rusqlite::params_from_iter(params), |row| {
13309 let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
13310 render_row_id_json(Some(&value))
13311 .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
13312 })
13313 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13314 let ids = by_table.entry(target.table.clone()).or_default();
13315 for row in rows {
13316 ids.insert(
13317 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
13318 );
13319 }
13320 }
13321 Ok(by_table)
13322 }
13323
13324 fn local_purge_values_match(
13325 target: &CompiledLocalDataPurgeTarget,
13326 values: &Map<String, Value>,
13327 ) -> bool {
13328 target.selectors.iter().all(|(column, allowed)| {
13329 values
13330 .get(column)
13331 .and_then(Value::as_str)
13332 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
13333 })
13334 }
13335
13336 pub fn purge_local_data(
13340 &mut self,
13341 input: &LocalDataPurgeInput,
13342 ) -> Result<LocalDataPurgeResult, String> {
13343 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
13344 self.cancel_sync_round();
13345 let meta_key = format!("localPurge:{}", input.purge_id);
13346 let applied_plan = self
13347 .conn
13348 .query_row(
13349 "SELECT value FROM _syncular_meta WHERE key = ?1",
13350 rusqlite::params![meta_key],
13351 |row| row.get::<_, String>(0),
13352 )
13353 .optional()
13354 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13355 if let Some(applied_plan) = applied_plan {
13356 if applied_plan != canonical_plan {
13357 return Err(format!(
13358 "sync.invalid_request: local purge id {:?} was already used with a different plan",
13359 input.purge_id
13360 ));
13361 }
13362 return Ok(LocalDataPurgeResult {
13363 already_applied: true,
13364 purged_rows: 0,
13365 dropped_commits: 0,
13366 });
13367 }
13368
13369 let _ = self.drop_previous_version();
13373
13374 let prior_outbox = self.outbox.clone();
13375 let prior_failed = self.failed_commits.clone();
13376 let prior_rejections = self.rejections.clone();
13377 let prior_conflicts = self.conflicts.clone();
13378 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13379 self.begin_observation("syncular_local_purge")?;
13380 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
13381 let row_ids = self.local_purge_base_row_ids(&targets)?;
13382 let retained_dropped = self.drop_failed_commits(|table, values| {
13383 targets.iter().any(|target| {
13384 target.table == table && Self::local_purge_values_match(target, values)
13385 })
13386 })?;
13387 let doomed = self
13388 .outbox
13389 .iter()
13390 .filter(|commit| {
13391 commit.ops.iter().any(|operation| {
13392 let matching_targets = targets
13393 .iter()
13394 .filter(|target| target.table == operation.table)
13395 .collect::<Vec<_>>();
13396 if matching_targets.is_empty() {
13397 return false;
13398 }
13399 if row_ids
13400 .get(&operation.table)
13401 .is_some_and(|ids| ids.contains(&operation.row_id))
13402 {
13403 return true;
13404 }
13405 operation.values.as_ref().is_some_and(|values| {
13406 matching_targets
13407 .iter()
13408 .any(|target| Self::local_purge_values_match(target, values))
13409 })
13410 })
13411 })
13412 .cloned()
13413 .collect::<Vec<_>>();
13414 let doomed_ids = doomed
13415 .iter()
13416 .map(|commit| commit.client_commit_id.clone())
13417 .collect::<BTreeSet<_>>();
13418 let mut batch = ChangeAccumulator::default();
13419 for commit in &retained_dropped {
13420 batch.conflicts = true;
13421 batch.rejections = true;
13422 batch.outcomes = true;
13423 for operation in &commit.ops {
13424 batch.table(&operation.table);
13425 }
13426 }
13427 let mut rejections = Vec::new();
13428 for commit in &doomed {
13429 for operation in &commit.ops {
13430 batch.table(&operation.table);
13431 }
13432 let results = commit
13433 .ops
13434 .iter()
13435 .enumerate()
13436 .map(|(op_index, operation)| {
13437 let rejection = RejectionRecord {
13438 client_commit_id: commit.client_commit_id.clone(),
13439 op_index: op_index as i32,
13440 code: "client.local_data_purged".to_owned(),
13441 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
13442 retryable: false,
13443 details: None,
13444 operation: Some(CommitOperation::from(operation)),
13445 };
13446 rejections.push(rejection.clone());
13447 CommitOperationOutcome::Error { rejection }
13448 })
13449 .collect::<Vec<_>>();
13450 self.persist_commit_outcome(
13451 &commit.client_commit_id,
13452 CommitOutcomeStatus::Rejected,
13453 &results,
13454 Some(&commit.ops),
13455 )?;
13456 self.delete_outbox_persisted(&commit.client_commit_id)?;
13457 }
13458 if !doomed.is_empty() {
13459 self.outbox
13460 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
13461 self.rejections.extend(rejections);
13462 self.prune_commit_outcomes()?;
13463 batch.status = true;
13464 batch.rejections = true;
13465 batch.outcomes = true;
13466 }
13467
13468 let mut purged_rows = 0usize;
13469 for (table, ids) in &row_ids {
13470 if ids.is_empty() {
13471 continue;
13472 }
13473 batch.table(table);
13474 for row_id in ids {
13475 self.delete_base_row(table, row_id)?;
13476 purged_rows += 1;
13477 }
13478 }
13479 self.rebuild_overlay_if_dirty();
13480 self.delete_unreferenced_cached_blobs();
13481 self.conn
13482 .execute(
13483 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
13484 rusqlite::params![meta_key, canonical_plan],
13485 )
13486 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13487 Ok((
13488 batch,
13489 LocalDataPurgeResult {
13490 already_applied: false,
13491 purged_rows,
13492 dropped_commits: doomed.len(),
13493 },
13494 ))
13495 })();
13496 let (batch, result) = match applied {
13497 Ok(value) => value,
13498 Err(error) => {
13499 self.rollback_observation("syncular_local_purge");
13500 self.outbox = prior_outbox;
13501 self.failed_commits = prior_failed;
13502 self.rejections = prior_rejections;
13503 self.conflicts = prior_conflicts;
13504 self.overlay_dirty.restore(prior_overlay_dirty.clone());
13505 return Err(error);
13506 }
13507 };
13508 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
13509 self.rollback_observation("syncular_local_purge");
13510 self.outbox = prior_outbox;
13511 self.failed_commits = prior_failed;
13512 self.rejections = prior_rejections;
13513 self.conflicts = prior_conflicts;
13514 self.overlay_dirty.restore(prior_overlay_dirty.clone());
13515 return Err(error);
13516 }
13517 Ok(result)
13518 }
13519
13520 pub fn rebootstrap_local_data(
13526 &mut self,
13527 input: &LocalDataRebootstrapInput,
13528 ) -> Result<LocalDataRebootstrapResult, String> {
13529 if input.rebootstrap_id.is_empty()
13530 || input.rebootstrap_id.len() > 128
13531 || !is_local_operation_code_like(&input.rebootstrap_id)
13532 {
13533 return Err(
13534 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
13535 .to_owned(),
13536 );
13537 }
13538 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
13539 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
13540 let (retained_commits, reset_subscriptions) =
13541 decode_local_rebootstrap_receipt(&persisted)?;
13542 return Ok(LocalDataRebootstrapResult {
13543 already_applied: true,
13544 retained_commits,
13545 reset_subscriptions,
13546 });
13547 }
13548 if self.stopped || self.schema_floor.is_some() {
13549 return Err(
13550 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
13551 .to_owned(),
13552 );
13553 }
13554
13555 let retained_commits = self.outbox.len();
13556 let reset_subscriptions = self.subs.len();
13557 let prior_subs = self.subs.clone();
13558 let prior_upgrading = self.upgrading;
13559 let prior_stopped = self.stopped;
13560 let prior_schema_floor = self.schema_floor.clone();
13561 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13562 let prior_sync_needed = self.sync_needed;
13563 let prior_sync_intents = self.sync_intent_queue.clone();
13564 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
13565
13566 self.cancel_sync_round();
13567 self.begin_observation("syncular_local_rebootstrap")?;
13568 let mut batch = ChangeAccumulator::default();
13569 let applied = (|| -> Result<(), String> {
13570 self.run_schema_reset_observed(&mut batch, false, false)?;
13571 self.conn
13572 .execute(
13573 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
13574 rusqlite::params![meta_key, receipt],
13575 )
13576 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13577 self.sync_needed = true;
13578 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13579 batch.status = true;
13580 Ok(())
13581 })();
13582 if let Err(error) = applied {
13583 self.rollback_observation("syncular_local_rebootstrap");
13584 self.subs = prior_subs;
13585 self.upgrading = prior_upgrading;
13586 self.stopped = prior_stopped;
13587 self.schema_floor = prior_schema_floor;
13588 self.overlay_dirty.restore(prior_overlay_dirty.clone());
13589 self.sync_needed = prior_sync_needed;
13590 self.sync_intent_queue = prior_sync_intents;
13591 return Err(error);
13592 }
13593 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
13594 self.rollback_observation("syncular_local_rebootstrap");
13595 self.subs = prior_subs;
13596 self.upgrading = prior_upgrading;
13597 self.stopped = prior_stopped;
13598 self.schema_floor = prior_schema_floor;
13599 self.overlay_dirty.restore(prior_overlay_dirty.clone());
13600 self.sync_needed = prior_sync_needed;
13601 self.sync_intent_queue = prior_sync_intents;
13602 return Err(error);
13603 }
13604
13605 Ok(LocalDataRebootstrapResult {
13606 already_applied: false,
13607 retained_commits,
13608 reset_subscriptions,
13609 })
13610 }
13611
13612 fn purge_scope_rows(
13617 &mut self,
13618 table_name: &str,
13619 effective: &[(String, Vec<String>)],
13620 ) -> Result<(), ()> {
13621 if effective.is_empty() {
13622 return Ok(());
13623 }
13624 let table = self.schema.table(table_name).ok_or(())?.clone();
13625 let mut clauses = Vec::new();
13626 let mut params: Vec<SqlValue> = Vec::new();
13627 for (variable, values) in effective {
13628 let column = table.scope_column(variable).ok_or(())?;
13629 let placeholders: Vec<String> = values
13630 .iter()
13631 .map(|v| {
13632 params.push(SqlValue::Text(v.clone()));
13633 "?".to_owned()
13634 })
13635 .collect();
13636 clauses.push(format!(
13637 "{} IN ({})",
13638 quote_ident(column),
13639 placeholders.join(", ")
13640 ));
13641 }
13642 let sql = format!(
13643 "DELETE FROM {} WHERE {}",
13644 base_table(table_name),
13645 clauses.join(" AND ")
13646 );
13647 self.overlay_dirty.table(table_name);
13648 self.conn
13649 .execute(&sql, rusqlite::params_from_iter(¶ms))
13650 .map_err(|_| ())?;
13651 self.conn
13652 .execute(
13653 &format!(
13654 "DELETE FROM {} WHERE {}",
13655 visible_table(table_name),
13656 clauses.join(" AND ")
13657 ),
13658 rusqlite::params_from_iter(¶ms),
13659 )
13660 .map_err(|_| ())?;
13661 Ok(())
13662 }
13663
13664 fn drop_doomed_outbox(
13667 &mut self,
13668 table_name: &str,
13669 effective: &[(String, Vec<String>)],
13670 ) -> Result<bool, String> {
13671 if effective.is_empty() {
13672 return Ok(false);
13673 }
13674 let Some(table) = self.schema.table(table_name).cloned() else {
13675 return Ok(false);
13676 };
13677 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
13678 for (variable, values) in effective {
13679 match table.scope_column(variable) {
13680 Some(column) => mappings.push((column, values)),
13681 None => return Ok(false), }
13683 }
13684 let doomed: Vec<OutboxCommit> = self
13685 .outbox
13686 .iter()
13687 .filter(|commit| {
13688 commit.ops.iter().any(|op| {
13689 op.upsert
13690 && op.table == table_name
13691 && op.values.as_ref().is_some_and(|values| {
13692 mappings
13693 .iter()
13694 .all(|(column, allowed)| match values.get(*column) {
13695 Some(Value::String(s)) => allowed.contains(s),
13696 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
13697 _ => false,
13698 })
13699 })
13700 })
13701 })
13702 .cloned()
13703 .collect();
13704 if doomed.is_empty() {
13705 return Ok(false);
13706 }
13707 let mut rejections = Vec::new();
13708 for commit in &doomed {
13709 let results = commit
13710 .ops
13711 .iter()
13712 .enumerate()
13713 .map(|(op_index, operation)| {
13714 let rejection = RejectionRecord {
13715 client_commit_id: commit.client_commit_id.clone(),
13716 op_index: op_index as i32,
13717 code: "sync.scope_revoked".to_owned(),
13718 message: "the commit was dropped because its effective scope was revoked"
13719 .to_owned(),
13720 retryable: false,
13721 details: None,
13722 operation: Some(CommitOperation::from(operation)),
13723 };
13724 rejections.push(rejection.clone());
13725 CommitOperationOutcome::Error { rejection }
13726 })
13727 .collect::<Vec<_>>();
13728 self.persist_commit_outcome(
13729 &commit.client_commit_id,
13730 CommitOutcomeStatus::Rejected,
13731 &results,
13732 Some(&commit.ops),
13733 )?;
13734 self.delete_outbox_persisted(&commit.client_commit_id)?;
13735 }
13736 self.prune_commit_outcomes()?;
13737 let doomed_ids = doomed
13738 .iter()
13739 .map(|commit| commit.client_commit_id.as_str())
13740 .collect::<BTreeSet<_>>();
13741 self.outbox
13742 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
13743 self.rejections.extend(rejections);
13744 Ok(true)
13745 }
13746
13747 pub fn upload_blob(
13754 &mut self,
13755 bytes: &[u8],
13756 media_type: Option<String>,
13757 name: Option<String>,
13758 ) -> Result<Value, String> {
13759 let blob_id = blob_id_for(bytes);
13760 let now = self.clock_now_ms();
13761 let tx = self
13762 .conn
13763 .transaction()
13764 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13765 tx.execute(
13766 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
13767 ON CONFLICT(blob_id) DO NOTHING",
13768 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
13769 )
13770 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13771 tx.execute(
13772 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
13773 rusqlite::params![blob_id, media_type, now],
13774 )
13775 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13776 tx.commit()
13777 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13778 self.enforce_blob_cache_cap();
13779 let mut obj = Map::new();
13780 obj.insert("blobId".to_owned(), Value::from(blob_id));
13781 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
13782 if let Some(mt) = media_type {
13783 obj.insert("mediaType".to_owned(), Value::from(mt));
13784 }
13785 if let Some(n) = name {
13786 obj.insert("name".to_owned(), Value::from(n));
13787 }
13788 Ok(Value::Object(obj))
13789 }
13790
13791 pub fn fetch_blob_bytes(
13795 &mut self,
13796 transport: &mut dyn Transport,
13797 blob_id_or_ref: &str,
13798 ) -> Result<FetchedBlob, (String, String)> {
13799 let simple = |m: String| ("client.failed".to_owned(), m);
13800 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
13801 blob_id_or_ref.to_owned()
13802 } else {
13803 let value: Value = serde_json::from_str(blob_id_or_ref)
13804 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
13805 value
13806 .get("blobId")
13807 .and_then(Value::as_str)
13808 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
13809 .to_owned()
13810 };
13811 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
13812 return Ok(cached);
13813 }
13814 if !self.transport_enabled {
13815 return Err(("sync.offline".into(), "transport gate is closed".into()));
13816 }
13817 #[cfg(feature = "bench-internals")]
13823 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
13824 let bytes = match transport
13825 .blob_download(&blob_id)
13826 .map_err(|e| (e.code, e.message))?
13827 {
13828 BlobDownload::Bytes(bytes) => bytes,
13829 BlobDownload::Url {
13830 url,
13831 url_expires_at_ms,
13832 } => {
13833 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
13835 return Err((
13836 "sync.segment_expired".to_owned(),
13837 format!(
13838 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
13839 ),
13840 ));
13841 }
13842 transport
13843 .fetch_blob_url(&url)
13844 .map_err(|e| (e.code, e.message))?
13845 }
13846 };
13847 #[cfg(feature = "bench-internals")]
13848 drop(download_phase);
13849 #[cfg(feature = "bench-internals")]
13850 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
13851 if blob_id_for(&bytes) != blob_id {
13853 return Err(simple(format!(
13854 "blob content address mismatch for {blob_id}"
13855 )));
13856 }
13857 #[cfg(feature = "bench-internals")]
13858 drop(validate_phase);
13859 #[cfg(feature = "bench-internals")]
13860 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
13861 let now = self.clock_now_ms();
13862 self.conn
13863 .execute(
13864 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
13865 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
13866 )
13867 .map_err(|e| simple(e.to_string()))?;
13868 self.conn
13869 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
13870 .map_err(|e| simple(e.to_string()))?;
13871 self.enforce_blob_cache_cap();
13872 #[cfg(feature = "bench-internals")]
13873 drop(insert_phase);
13874 self.get_cached_blob_bytes(&blob_id)
13875 .map_err(simple)?
13876 .ok_or_else(|| simple("blob cache write failed".to_owned()))
13877 }
13878
13879 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
13880 #[cfg(feature = "bench-internals")]
13881 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
13882 let mut stmt = self
13883 .conn
13884 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
13885 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13886 let mut rows = stmt
13887 .query(rusqlite::params![blob_id])
13888 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13889 if let Some(row) = rows
13890 .next()
13891 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13892 {
13893 let bytes: Vec<u8> = row
13894 .get(0)
13895 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13896 let byte_length: i64 = row
13897 .get(1)
13898 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13899 let media_type: Option<String> = row
13900 .get(2)
13901 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13902 return Ok(Some(FetchedBlob {
13903 blob_id: blob_id.to_owned(),
13904 byte_length,
13905 bytes,
13906 media_type,
13907 }));
13908 }
13909 Ok(None)
13910 }
13911
13912 fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
13914 let pending: Vec<(String, Option<String>)> = {
13915 let mut stmt = self
13916 .conn
13917 .prepare(
13918 "SELECT blob_id, media_type FROM (
13919 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
13920 UNION ALL
13921 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
13922 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
13923 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
13924 ) ORDER BY created_at_ms, blob_id",
13925 )
13926 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13927 let rows = stmt
13928 .query_map([], |row| {
13929 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
13930 })
13931 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13932 rows.collect::<Result<_, _>>()
13933 .map_err(|error| match error {
13934 rusqlite::Error::InvalidColumnType(..)
13935 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
13936 "sync.local_corrupt",
13937 "Pending blob upload metadata is invalid",
13938 ),
13939 _ => TransportError::new("client.failed", error.to_string()),
13940 })?
13941 };
13942 let mut uploads = Vec::new();
13943 for (blob_id, media_type) in pending {
13944 let bytes = (|| {
13945 let stored: Option<(SqlValue, SqlValue)> = self
13946 .conn
13947 .query_row(
13948 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
13949 rusqlite::params![blob_id],
13950 |row| Ok((row.get(0)?, row.get(1)?)),
13951 )
13952 .optional()
13953 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13954 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
13955 return Err(TransportError::new(
13956 "sync.local_corrupt",
13957 "Pending blob upload body is missing or corrupt",
13958 ));
13959 };
13960 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
13961 return Err(TransportError::new(
13962 "sync.local_corrupt",
13963 "Pending blob upload body is missing or corrupt",
13964 ));
13965 }
13966 Ok(bytes)
13967 })();
13968 let failed = bytes.is_err();
13969 uploads.push(crate::round::PendingUpload {
13970 id: blob_id,
13971 bytes,
13972 media_type,
13973 });
13974 if failed {
13975 break;
13976 }
13977 }
13978 Ok(uploads)
13979 }
13980
13981 #[cfg(test)]
13982 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
13983 for upload in self.pending_blob_uploads()? {
13984 self.upload_one(
13985 transport,
13986 &upload.id,
13987 &upload.bytes?,
13988 upload.media_type.as_deref(),
13989 )?;
13990 self.conn
13991 .execute(
13992 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
13993 rusqlite::params![upload.id],
13994 )
13995 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13996 }
13997 Ok(())
13998 }
13999
14000 #[cfg(test)]
14007 fn upload_one(
14008 &self,
14009 transport: &mut dyn Transport,
14010 blob_id: &str,
14011 bytes: &[u8],
14012 media_type: Option<&str>,
14013 ) -> Result<(), TransportError> {
14014 crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
14015 }
14016
14017 fn visible_blob_ids_sql(&self) -> String {
14018 let mut selects = Vec::new();
14019 for table in &self.schema.tables {
14020 for column in table
14021 .columns
14022 .iter()
14023 .filter(|column| column.ty == ColumnType::BlobRef)
14024 {
14025 let identifier = quote_ident(&column.name);
14026 selects.push(format!(
14027 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
14028 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
14029 AND json_type({identifier}, '$.blobId') = 'text'",
14030 quote_ident(&table.name),
14031 ));
14032 }
14033 }
14034 if selects.is_empty() {
14035 "SELECT NULL AS blob_id WHERE 0".to_owned()
14036 } else {
14037 selects.join(" UNION ")
14038 }
14039 }
14040
14041 fn enforce_blob_cache_cap(&self) {
14044 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
14045 return;
14046 };
14047 let mut total: i64 = self
14048 .conn
14049 .query_row(
14050 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
14051 [],
14052 |row| row.get(0),
14053 )
14054 .unwrap_or(0);
14055 if total <= max_bytes {
14056 return;
14057 }
14058 let query = format!(
14059 "SELECT blob_id, byte_length FROM _syncular_blobs
14060 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
14061 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
14062 AND blob_id NOT IN ({})
14063 ORDER BY created_at_ms ASC, blob_id ASC",
14064 self.visible_blob_ids_sql(),
14065 );
14066 let candidates: Vec<(String, i64)> = {
14067 let Ok(mut stmt) = self.conn.prepare(&query) else {
14068 return;
14069 };
14070 let Ok(rows) = stmt.query_map([], |row| {
14071 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
14072 }) else {
14073 return;
14074 };
14075 rows.filter_map(Result::ok).collect()
14076 };
14077 for (blob_id, byte_length) in candidates {
14078 if total <= max_bytes {
14079 break;
14080 }
14081 let _ = self.conn.execute(
14082 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
14083 rusqlite::params![blob_id],
14084 );
14085 total -= byte_length;
14086 }
14087 }
14088
14089 fn delete_unreferenced_cached_blobs(&mut self) {
14090 #[cfg(feature = "bench-internals")]
14091 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
14092 if !self.schema_has_blobs() {
14093 return;
14094 }
14095 self.rebuild_overlay_if_dirty();
14096 let _ = self.conn.execute(
14097 &format!(
14098 "DELETE FROM _syncular_blobs
14099 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
14100 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
14101 AND blob_id NOT IN ({})",
14102 self.visible_blob_ids_sql(),
14103 ),
14104 [],
14105 );
14106 }
14107
14108 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
14113 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
14114 return sql.clone();
14115 }
14116 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
14117 columns.push(quote_ident("_syncular_version"));
14118 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
14119 let primary_key = quote_ident(&table.primary_key);
14120 let updates = columns
14121 .iter()
14122 .filter(|column| **column != primary_key)
14123 .map(|column| format!("{column}=excluded.{column}"))
14124 .collect::<Vec<_>>()
14125 .join(", ");
14126 let sql = format!(
14127 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
14128 columns.join(", "),
14129 placeholders.join(", ")
14130 );
14131 self.insert_sql
14132 .borrow_mut()
14133 .insert(full_table.to_owned(), sql.clone());
14134 sql
14135 }
14136
14137 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
14138 self.overlay_dirty.table(table_name);
14139 self.write_row(&base_table(table_name), table_name, row, version)
14140 }
14141
14142 fn write_row(
14143 &self,
14144 full_table: &str,
14145 table_name: &str,
14146 row: &Row,
14147 version: i64,
14148 ) -> Result<(), String> {
14149 #[cfg(feature = "bench-internals")]
14150 let _phase = self.benchmark_phases.start(Phase::RowWrite);
14151 let table = self
14152 .schema
14153 .table(table_name)
14154 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
14155 let sql = self.insert_row_sql(full_table, table);
14156 let mut stmt = self
14157 .conn
14158 .prepare_cached(&sql)
14159 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14160 let params = row
14161 .iter()
14162 .map(RowParam::Cell)
14163 .chain(std::iter::once(RowParam::Version(version)));
14164 stmt.execute(rusqlite::params_from_iter(params))
14165 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14166 Ok(())
14167 }
14168
14169 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
14170 let table = self
14171 .schema
14172 .table(table_name)
14173 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
14174 self.overlay_dirty.table(table_name);
14175 let sql = format!(
14176 "DELETE FROM {} WHERE {}",
14177 base_table(table_name),
14178 row_id_predicate(table)
14179 );
14180 let mut stmt = self
14181 .conn
14182 .prepare_cached(&sql)
14183 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14184 stmt.execute(rusqlite::params![row_id])
14185 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14186 Ok(())
14187 }
14188
14189 fn has_acknowledged_rows(&self) -> bool {
14190 self.conn
14191 .query_row(
14192 "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
14193 [],
14194 |row| row.get(0),
14195 )
14196 .expect("read acknowledged intent")
14197 }
14198
14199 fn acknowledged_operations(
14200 &self,
14201 rows: Option<&HashSet<(&str, &str)>>,
14202 tables: Option<&BTreeSet<&str>>,
14203 ) -> Result<Vec<OutboxOp>, String> {
14204 let keys = rows.map(|rows| {
14205 serde_json::to_string(
14206 &rows
14207 .iter()
14208 .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
14209 .collect::<Vec<_>>(),
14210 )
14211 .expect("row keys")
14212 });
14213 let mut statement = self
14214 .conn
14215 .prepare("SELECT op_json,intent_json FROM _syncular_acked_rows WHERE (?1 IS NULL OR json_array(tbl,id) IN(SELECT value FROM json_each(?1))) AND (?2 IS NULL OR tbl IN(SELECT value FROM json_each(?2))) ORDER BY commit_seq,idx")
14216 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14217 let rows = statement
14218 .query_map(
14219 rusqlite::params![
14220 keys,
14221 tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
14222 ],
14223 |row| {
14224 #[cfg(test)]
14225 self.acknowledged_replay_count
14226 .set(self.acknowledged_replay_count.get() + 1);
14227 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
14228 },
14229 )
14230 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14231 rows.map(|row| {
14232 let (op, intent) =
14233 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14234 let op: CommitOperation =
14235 serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
14236 let table = self
14237 .schema
14238 .table(&op.table)
14239 .ok_or_else(|| "sync.unknown_table".to_owned())?;
14240 let values = if op.op == "upsert" && self.visible_row(table, &op.row_id).is_none() {
14241 Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
14242 } else {
14243 op.values
14244 };
14245 Ok(OutboxOp {
14246 table: op.table,
14247 row_id: op.row_id,
14248 upsert: op.op == "upsert",
14249 base_version: op.base_version,
14250 values,
14251 })
14252 })
14253 .collect()
14254 }
14255
14256 fn drop_acknowledged_rows(
14257 &self,
14258 matches: impl Fn(&str, &Map<String, Value>) -> bool,
14259 ) -> Result<Vec<OutboxCommit>, String> {
14260 let mut statement = self
14261 .conn
14262 .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
14263 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14264 let rows = statement
14265 .query_map([], |row| {
14266 Ok((
14267 row.get::<_, String>(0)?,
14268 row.get::<_, String>(1)?,
14269 row.get::<_, String>(2)?,
14270 row.get::<_, String>(3)?,
14271 ))
14272 })
14273 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14274 let mut doomed = HashSet::new();
14275 for row in rows {
14276 let (id, table, intent, row_id) =
14277 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14278 let values =
14279 serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
14280 let base_matches = self
14281 .schema
14282 .table(&table)
14283 .and_then(|schema| self.stored_row(schema, &row_id, true))
14284 .is_some_and(|(base, _)| {
14285 let schema = self.schema.table(&table).expect("acknowledged table");
14286 let values = schema
14287 .columns
14288 .iter()
14289 .zip(base.iter())
14290 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14291 .collect();
14292 matches(&table, &values)
14293 });
14294 if matches(&table, &values) || base_matches {
14295 doomed.insert(id);
14296 }
14297 }
14298 let mut dropped = Vec::new();
14299 for id in doomed {
14300 let mut statement = self
14301 .conn
14302 .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
14303 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14304 let operations = statement
14305 .query_map([&id], |row| row.get::<_, String>(0))
14306 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14307 let mut ops = Vec::new();
14308 for operation in operations {
14309 let json = operation
14310 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14311 let op: CommitOperation =
14312 serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
14313 self.overlay_dirty.table(&op.table);
14314 ops.push(OutboxOp {
14315 table: op.table,
14316 row_id: op.row_id,
14317 upsert: op.op == "upsert",
14318 base_version: op.base_version,
14319 values: op.values,
14320 });
14321 }
14322 dropped.push(OutboxCommit {
14323 client_commit_id: id.clone(),
14324 ops,
14325 });
14326 self.conn
14327 .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
14328 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14329 }
14330 Ok(dropped)
14331 }
14332
14333 fn rebuild_overlay_if_dirty(&mut self) {
14336 if self.overlay_dirty.get() {
14337 self.rebuild_overlay();
14338 }
14339 }
14340
14341 fn rebuild_overlay(&mut self) {
14345 #[cfg(feature = "bench-internals")]
14346 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
14347 #[cfg(test)]
14348 self.overlay_rebuild_count
14349 .set(self.overlay_rebuild_count.get() + 1);
14350 let dirty = self.overlay_dirty.snapshot();
14351 let tables: Vec<_> = self
14352 .schema
14353 .tables
14354 .iter()
14355 .filter(|table| {
14356 dirty
14357 .as_ref()
14358 .is_none_or(|names| names.is_empty() || names.contains(&table.name))
14359 })
14360 .cloned()
14361 .collect();
14362 let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
14363 self.exec("SAVEPOINT syncular_overlay");
14364 for table in &tables {
14365 for index in &table.fts_indexes {
14366 let _ = self.drop_fts_triggers(index);
14367 }
14368 let visible = visible_table(&table.name);
14369 let base = base_table(&table.name);
14370 self.exec(&format!("DELETE FROM {visible}"));
14371 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
14372 }
14373 let acknowledged = self
14374 .acknowledged_operations(None, Some(&names))
14375 .expect("decode acknowledged intent");
14376 self.apply_outbox_ops(
14377 acknowledged
14378 .iter()
14379 .filter(|op| names.contains(op.table.as_str())),
14380 );
14381 for (commit, initial) in &self.failed_commits {
14382 for operation in &commit.ops {
14383 if !names.contains(operation.table.as_str()) || !operation.upsert {
14384 continue;
14385 }
14386 if let Some(table) = self.schema.table(&operation.table) {
14387 if self.visible_row(table, &operation.row_id).is_none()
14388 && operation.values.as_ref().is_some_and(|values| {
14389 table
14390 .columns
14391 .iter()
14392 .all(|column| values.contains_key(&column.name))
14393 })
14394 {
14395 let key = serde_json::to_string(&(
14396 operation.table.clone(),
14397 operation.row_id.clone(),
14398 ))
14399 .expect("row key serialization");
14400 if let Some(Value::Object(values)) = initial.get(&key) {
14401 self.apply_outbox_ops(&[OutboxOp {
14402 values: Some(values.clone()),
14403 ..operation.clone()
14404 }]);
14405 }
14406 }
14407 }
14408 }
14409 self.apply_outbox_ops(
14410 commit
14411 .ops
14412 .iter()
14413 .filter(|op| names.contains(op.table.as_str())),
14414 );
14415 }
14416 for commit in &self.outbox {
14417 self.apply_outbox_ops(
14418 commit
14419 .ops
14420 .iter()
14421 .filter(|op| names.contains(op.table.as_str())),
14422 );
14423 }
14424 for table in &tables {
14425 for index in &table.fts_indexes {
14426 let _ = self.rebuild_fts_projection(table, index);
14427 let _ = self.create_fts_triggers(table, index);
14428 }
14429 }
14430 self.exec("RELEASE syncular_overlay");
14431 self.overlay_dirty.set(false);
14432 }
14433
14434 fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
14439 #[cfg(feature = "bench-internals")]
14440 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
14441 for op in ops {
14442 #[cfg(feature = "bench-internals")]
14443 if let Some(phase) = &mut phase {
14444 phase.unit();
14445 }
14446 if op.upsert {
14447 if let (Some(table), Some(values)) = (
14448 self.schema
14449 .table(&op.table)
14450 .filter(|table| table.indexes.iter().any(|index| index.unique)),
14451 op.values.as_ref(),
14452 ) {
14453 let mut intended: Map<String, Value> = self
14454 .visible_row(table, &op.row_id)
14455 .map(|(row, _)| {
14456 table
14457 .columns
14458 .iter()
14459 .zip(&row)
14460 .map(|(column, value)| {
14461 (column.name.clone(), column_value_to_json(value))
14462 })
14463 .collect()
14464 })
14465 .unwrap_or_default();
14466 intended.extend(values.clone());
14467 if !self
14468 .unique_conflicts(table, &intended, false)
14469 .expect("overlay unique-key read failed")
14470 .is_empty()
14471 {
14472 continue;
14473 }
14474 }
14475 }
14476 let _ = self.apply_outbox_op(op);
14477 }
14478 }
14479
14480 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
14481 let Some(table) = self.schema.table(&op.table) else {
14482 return Ok(());
14483 };
14484 let visible = visible_table(&table.name);
14485 if !op.upsert {
14486 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
14487 self.conn
14488 .prepare_cached(&sql)
14489 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
14490 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14491 return Ok(());
14492 }
14493 let Some(values) = op.values.as_ref() else {
14494 return Ok(());
14495 };
14496 let full = table
14501 .columns
14502 .iter()
14503 .all(|column| values.contains_key(&column.name));
14504 let local = self.visible_row(table, &op.row_id);
14505 if local.is_none() && !full {
14506 return Ok(());
14507 }
14508 let mut row: Row = Vec::with_capacity(table.columns.len());
14509 for (index, column) in table.columns.iter().enumerate() {
14510 let incoming = match values.get(&column.name) {
14511 Some(raw) => json_to_column_value(column, Some(raw)),
14512 None => Ok(local
14513 .as_ref()
14514 .and_then(|(values, _)| values.get(index).cloned())
14515 .flatten()),
14516 };
14517 match incoming {
14518 Ok(value) => row.push(value),
14519 Err(_) => return Ok(()),
14520 }
14521 }
14522 let version = local.as_ref().map_or(-1, |(_, version)| *version);
14523 self.write_row(&visible, &table.name, &row, version)
14524 .map_err(|error| {
14525 if error.starts_with("UNIQUE constraint failed:") {
14526 "sync.constraint_violation: local write violates a unique constraint".to_owned()
14527 } else {
14528 error
14529 }
14530 })
14531 }
14532
14533 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
14536 self.stored_row(table, row_id, false)
14537 }
14538
14539 fn stored_row(&self, table: &TableSchema, row_id: &str, base: bool) -> Option<(Row, i64)> {
14540 let sql = format!(
14541 "SELECT * FROM {} WHERE {}",
14542 if base {
14543 base_table(&table.name)
14544 } else {
14545 visible_table(&table.name)
14546 },
14547 row_id_predicate(table)
14548 );
14549 let mut stmt = self.conn.prepare_cached(&sql).ok()?;
14550 let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
14551 let row = rows.next().ok()??;
14552 let mut values = Vec::with_capacity(table.columns.len());
14553 for (index, column) in table.columns.iter().enumerate() {
14554 let raw = row.get_ref(index).ok()?;
14555 let json = sql_ref_to_json(column, raw);
14556 values.push(json_to_column_value(column, Some(&json)).ok()?);
14557 }
14558 let version: i64 = row.get(table.columns.len()).ok()?;
14559 Some((values, version))
14560 }
14561
14562 fn exec(&self, sql: &str) {
14563 let _ = self.conn.execute_batch(sql);
14564 }
14565
14566 #[must_use]
14570 pub fn realtime_policy(&self) -> RealtimePolicy {
14571 self.realtime_policy
14572 }
14573
14574 #[must_use]
14576 pub fn realtime_state(&self) -> RealtimeState {
14577 self.realtime_state
14578 }
14579
14580 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
14583 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
14584 return Err(
14585 "sync.invalid_request: disconnect realtime before changing the realtime policy"
14586 .to_owned(),
14587 );
14588 }
14589 self.realtime_policy = policy;
14590 match policy {
14591 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
14592 RealtimePolicy::Required | RealtimePolicy::Optional
14593 if self.realtime_state == RealtimeState::Disabled =>
14594 {
14595 self.set_realtime_state(RealtimeState::Disconnected, None);
14596 }
14597 RealtimePolicy::Required | RealtimePolicy::Optional => {}
14598 }
14599 Ok(())
14600 }
14601
14602 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
14605 self.realtime_state = state;
14606 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
14607 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
14608 self.realtime_reason_code = None;
14609 self.realtime_retry_delay_ms = None;
14610 }
14611 }
14612
14613 #[must_use]
14614 fn realtime_connected(&self) -> bool {
14615 self.realtime_state == RealtimeState::Connected
14616 }
14617
14618 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
14619 if !self.transport_enabled {
14620 return Err("sync.offline: transport gate is closed".into());
14621 }
14622 if self.realtime_policy == RealtimePolicy::Off {
14623 return Err(
14624 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
14625 );
14626 }
14627 if self.realtime_connected() {
14628 return Ok(());
14629 }
14630 self.realtime_state = RealtimeState::Connecting;
14631 match transport.realtime_connect_for_client(&self.client_id) {
14632 Ok(()) => {
14633 self.set_realtime_state(RealtimeState::Connected, None);
14634 Ok(())
14635 }
14636 Err(error) => {
14637 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
14638 Err(format!("{}: {}", error.code, error.message))
14639 }
14640 }
14641 }
14642
14643 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
14644 self.cancel_sync_round();
14645 if self.realtime_state == RealtimeState::Disconnected
14648 || self.realtime_state == RealtimeState::Disabled
14649 {
14650 return;
14651 }
14652 let _ = transport.realtime_close();
14653 self.set_realtime_state(
14654 if self.realtime_policy == RealtimePolicy::Off {
14655 RealtimeState::Disabled
14656 } else {
14657 RealtimeState::Disconnected
14658 },
14659 None,
14660 );
14661 self.presence.clear(); }
14663
14664 pub fn set_presence(
14670 &mut self,
14671 transport: &mut dyn Transport,
14672 scope_key: &str,
14673 doc: Option<&Value>,
14674 ) -> Result<(), String> {
14675 if !self.transport_enabled {
14676 return Err("sync.offline: transport gate is closed".into());
14677 }
14678 if !self.realtime_connected() {
14679 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
14680 }
14681 let text = encode_presence_publish(scope_key, doc);
14682 transport
14683 .realtime_send(&text)
14684 .map_err(|e| format!("{}: {}", e.code, e.message))
14685 }
14686
14687 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
14689 self.presence
14690 .get(scope_key)
14691 .map(|peers| peers.values().cloned().collect())
14692 .unwrap_or_default()
14693 }
14694
14695 fn apply_presence(
14697 &mut self,
14698 scope_key: String,
14699 kind: Option<PresenceKind>,
14700 actor_id: Option<String>,
14701 client_id: Option<String>,
14702 doc: Option<Value>,
14703 error: Option<String>,
14704 ) {
14705 if error.is_some() {
14708 return;
14709 }
14710 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
14711 return;
14712 };
14713 let peer_key = format!("{actor_id} {client_id}");
14714 match kind {
14715 PresenceKind::Leave => {
14716 if let Some(peers) = self.presence.get_mut(&scope_key) {
14717 peers.remove(&peer_key);
14718 if peers.is_empty() {
14719 self.presence.remove(&scope_key);
14720 }
14721 }
14722 }
14723 _ => {
14724 let doc = match doc {
14725 Some(Value::Object(_)) => doc.unwrap(),
14726 _ => return,
14727 };
14728 self.presence.entry(scope_key).or_default().insert(
14729 peer_key,
14730 PresencePeer {
14731 actor_id,
14732 client_id,
14733 doc,
14734 },
14735 );
14736 }
14737 }
14738 }
14739
14740 pub fn on_realtime_text(&mut self, text: &str) {
14742 match parse_control(text) {
14743 Ok(ControlMessage::Hello { requires_sync, .. }) => {
14744 if requires_sync {
14745 self.set_sync_needed(true, true);
14747 }
14748 }
14749 Ok(ControlMessage::Presence {
14750 scope_key,
14751 kind,
14752 actor_id,
14753 client_id,
14754 doc,
14755 error,
14756 ..
14757 }) => {
14758 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
14759 }
14760 Ok(ControlMessage::Wake { .. }) => {
14761 self.set_sync_needed(true, true);
14763 }
14764 _ => {}
14765 }
14766 }
14767
14768 pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
14772 let mut transport = crate::round::DownloadResults::default();
14773 self.on_realtime_binary(&mut transport, bytes);
14774 transport.controls
14775 }
14776
14777 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
14778 if self.stopped || !self.transport_enabled {
14779 return;
14780 }
14781 #[cfg(feature = "bench-internals")]
14782 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
14783 let message = match decode_message(bytes) {
14784 Ok(m) if m.msg_kind == MsgKind::Response => m,
14785 _ => {
14786 self.set_sync_needed(true, true);
14787 return;
14788 }
14789 };
14790 #[cfg(feature = "bench-internals")]
14791 drop(decode_phase);
14792 let mut frames = message.frames.into_iter();
14793 let mut applied_cursor: Option<i64> = None;
14794 let mut any_covered = false;
14795 let mut dropped = false;
14796 while let Some(frame) = frames.next() {
14797 let Frame::SubStart {
14798 id,
14799 status,
14800 effective_scopes,
14801 ..
14802 } = frame
14803 else {
14804 continue;
14805 };
14806 let mut body = Vec::new();
14807 let mut next_cursor: Option<i64> = None;
14808 for inner in frames.by_ref() {
14809 match inner {
14810 Frame::SubEnd {
14811 next_cursor: nc, ..
14812 } => {
14813 next_cursor = Some(nc);
14814 break;
14815 }
14816 Frame::Unknown { .. } => {}
14817 other => body.push(other),
14818 }
14819 }
14820 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
14821 dropped = true;
14822 continue;
14823 };
14824 let sub = &self.subs[sub_index];
14825 if status != SubStatus::Active
14828 || sub.state != SubState::Active
14829 || sub.bootstrap_state.is_some()
14830 || !sub.synced_once
14831 {
14832 dropped = true;
14833 continue;
14834 }
14835 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
14836 any_covered = true;
14838 continue;
14839 }
14840 let previous_effective = self.subs[sub_index].effective.clone();
14841 let previous_cursor = self.subs[sub_index].cursor;
14842 self.subs[sub_index].effective = Some(effective_scopes);
14843 let mut failed = false;
14844 for inner in body {
14845 match inner {
14846 Frame::Commit {
14847 tables,
14848 changes,
14849 commit_seq,
14850 ..
14851 } => {
14852 if self
14853 .apply_commit_frame(&tables, &changes, Some(commit_seq))
14854 .is_err()
14855 {
14856 failed = true;
14857 break;
14858 }
14859 }
14860 _ => {
14861 failed = true;
14862 break;
14863 }
14864 }
14865 }
14866 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
14867 self.apply_sub_end(sub_index, cursor, None)
14868 .ok()
14869 .map(|()| cursor)
14870 });
14871 let Some(next_cursor) = committed else {
14872 self.subs[sub_index].effective = previous_effective;
14873 self.subs[sub_index].cursor = previous_cursor;
14874 dropped = true;
14875 continue;
14876 };
14877 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
14878 }
14879 if let Some(cursor) = applied_cursor {
14880 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
14882 let _ = transport.realtime_send(&ack);
14883 } else if !any_covered || dropped {
14884 self.set_sync_needed(true, true);
14886 }
14887 }
14888
14889 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
14893 if !self.realtime_connected() {
14894 return;
14895 }
14896 let floor = self
14897 .subs
14898 .iter()
14899 .filter(|s| {
14900 s.state == SubState::Active
14901 && s.bootstrap_state.is_none()
14902 && s.synced_once
14903 && s.cursor >= 0
14904 })
14905 .map(|s| s.cursor)
14906 .min();
14907 if let Some(cursor) = floor {
14908 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
14909 let _ = transport.realtime_send(&ack);
14910 }
14911 }
14912}
14913
14914#[cfg(test)]
14915mod previous_version_wiring_tests {
14916 use super::*;
14917 use serde_json::{json, Value};
14918
14919 fn previous_version_schema(version: i32) -> Value {
14920 json!({
14921 "version": version,
14922 "tables": [{
14923 "name": "tasks",
14924 "primaryKey": "id",
14925 "columns": [
14926 { "name": "id", "type": "string", "nullable": false },
14927 { "name": "note", "type": "string", "nullable": true }
14928 ],
14929 "scopes": []
14930 }]
14931 })
14932 }
14933
14934 fn previous_version_limits() -> crate::api::ClientLimits {
14935 crate::api::ClientLimits {
14936 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
14937 enabled: true,
14938 ..Default::default()
14939 }),
14940 ..Default::default()
14941 }
14942 }
14943
14944 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
14946 previous_version_limits()
14947 .previous_version_context
14948 .expect("configured")
14949 }
14950
14951 fn recapture_previous_version(client: &SyncClient) {
14954 let replica_path = client
14955 .previous_version_replica_path()
14956 .expect("file-backed replica");
14957 let outcome = crate::previous_version::capture_previous_version_from_replica(
14958 &client.conn,
14959 &replica_path,
14960 2,
14961 2,
14962 &previous_version_config(),
14963 1,
14964 )
14965 .expect("capture");
14966 assert!(matches!(
14967 outcome,
14968 crate::previous_version::CaptureOutcome::Captured(_)
14969 ));
14970 }
14971
14972 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
14973 Subscription {
14974 id: "wiring-sub".to_owned(),
14975 table: "tasks".to_owned(),
14976 requested: Vec::new(),
14977 params: None,
14978 cursor,
14979 bootstrap_state: bootstrap_state.map(str::to_owned),
14980 state,
14981 reason_code: None,
14982 effective: None,
14983 synced_once: false,
14984 }
14985 }
14986
14987 #[test]
14988 fn previous_version_read_reasons_come_from_live_client_state() {
14989 use crate::previous_version::{
14990 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
14991 };
14992 let path = std::env::temp_dir()
14993 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
14994 .to_string_lossy()
14995 .into_owned();
14996 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
14997 {
14998 let mut setup = SyncClient::open_path(
14999 "wiring".to_owned(),
15000 &previous_version_schema(1),
15001 previous_version_limits(),
15002 &path,
15003 )
15004 .expect("v1 install");
15005 setup
15006 .mutate(vec![Mutation::Upsert {
15007 table: "tasks".to_owned(),
15008 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
15009 base_version: None,
15010 }])
15011 .expect("seed a row");
15012 }
15013 let mut client = SyncClient::open_path(
15014 "wiring".to_owned(),
15015 &previous_version_schema(2),
15016 previous_version_limits(),
15017 &path,
15018 )
15019 .expect("v2 bump captures");
15020 assert!(std::path::Path::new(&container).exists());
15021 let spec = PreviousVersionReadSpec {
15022 table: "tasks".to_owned(),
15023 ..Default::default()
15024 };
15025
15026 recapture_previous_version(&client);
15028 client.lease_state = Some(LeaseState {
15029 lease_id: None,
15030 expires_at_ms: None,
15031 error_code: Some("lease.expired".to_owned()),
15032 });
15033 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
15034 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
15035 assert!(!snapshot.available);
15036 assert!(!std::path::Path::new(&container).exists());
15037
15038 recapture_previous_version(&client);
15040 client.set_now_ms(1_000);
15041 client.lease_state = Some(LeaseState {
15042 lease_id: Some("lease".to_owned()),
15043 expires_at_ms: Some(1_000),
15044 error_code: None,
15045 });
15046 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
15047 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
15048 assert!(!std::path::Path::new(&container).exists());
15049 client.lease_state = None;
15050
15051 recapture_previous_version(&client);
15053 client.subs.push(subscription(SubState::Revoked, 0, None));
15054 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
15055 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
15056 assert!(!std::path::Path::new(&container).exists());
15057 client.subs.clear();
15058
15059 recapture_previous_version(&client);
15062 client
15063 .subs
15064 .push(subscription(SubState::Active, 0, Some("resume")));
15065 assert!(!client.previous_version_coverage_complete());
15066 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
15067 assert!(snapshot.available);
15068 assert_eq!(snapshot.previous_version, Some(2));
15069 assert_eq!(snapshot.rows.len(), 1);
15070
15071 recapture_previous_version(&client);
15072 client.subs[0].bootstrap_state = None;
15073 assert!(client.previous_version_coverage_complete());
15074 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
15075 assert_eq!(
15076 snapshot.reason,
15077 Some(PreviousVersionReason::CoverageComplete)
15078 );
15079 assert!(!std::path::Path::new(&container).exists());
15080 client.subs.clear();
15081
15082 assert!(!client.previous_version_coverage_complete());
15084
15085 drop(client);
15086 let _ = std::fs::remove_file(&path);
15087 }
15088
15089 #[test]
15096 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
15097 let path = std::env::temp_dir()
15098 .join(format!(
15099 "syncular-prev-lifetime-{}.db",
15100 uuid::Uuid::new_v4()
15101 ))
15102 .to_string_lossy()
15103 .into_owned();
15104 let container = format!(
15105 "{path}{}",
15106 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
15107 );
15108 let exists = || std::path::Path::new(&container).exists();
15109 {
15110 let mut setup = SyncClient::open_path(
15111 "lifetime".to_owned(),
15112 &previous_version_schema(1),
15113 previous_version_limits(),
15114 &path,
15115 )
15116 .expect("v1 install");
15117 setup
15118 .mutate(vec![Mutation::Upsert {
15119 table: "tasks".to_owned(),
15120 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
15121 base_version: None,
15122 }])
15123 .expect("seed a row");
15124 }
15125 let mut client = SyncClient::open_path(
15126 "lifetime".to_owned(),
15127 &previous_version_schema(2),
15128 previous_version_limits(),
15129 &path,
15130 )
15131 .expect("v2 bump captures");
15132 client.set_now_ms(1_000);
15135 assert!(exists());
15136
15137 client.lease_state = Some(LeaseState {
15139 lease_id: None,
15140 expires_at_ms: None,
15141 error_code: Some("lease.expired".to_owned()),
15142 });
15143 client.previous_version_lifetime_check();
15144 assert!(!exists());
15145 client.lease_state = None;
15146
15147 recapture_previous_version(&client);
15150 client.subs.push(subscription(SubState::Active, 0, None));
15151 client.previous_version_lifetime_check();
15152 assert!(!exists());
15153 client.subs.clear();
15154
15155 recapture_previous_version(&client);
15157 client.subs.push(subscription(SubState::Revoked, 0, None));
15158 client.previous_version_lifetime_check();
15159 assert!(!exists());
15160 client.subs.clear();
15161
15162 recapture_previous_version(&client);
15165 client
15166 .subs
15167 .push(subscription(SubState::Active, 0, Some("resume")));
15168 client.previous_version_lifetime_check();
15169 assert!(exists());
15170 client.subs.clear();
15171
15172 drop(client);
15173 let _ = std::fs::remove_file(&path);
15174 let _ = std::fs::remove_file(&container);
15175 let _ = std::fs::remove_file(format!("{container}-wal"));
15176 let _ = std::fs::remove_file(format!("{container}-shm"));
15177 }
15178}