1use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use ssp2::encode_message;
12#[cfg(test)]
13use std::cell::Cell;
14use std::cell::RefCell;
15use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
16
17use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
18use rusqlite::{Connection, OpenFlags, OptionalExtension};
19use serde::{Deserialize, Serialize};
20use serde_json::{json, Map, Value};
21use sha2::{Digest, Sha256};
22use ssp2::decode::WIRE_VERSION;
23use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
24use ssp2::primitives::RawJson;
25use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
26use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
27
28use crate::api::{
29 ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30 ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31 ClientDiagnosticsStorage, ClientError, ClientLimits, CommandEffects, CommitDelivery,
32 CommitOperation, CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery,
33 CommitOutcomeResolution, CommitOutcomeStatus, ConflictRecord, CoverageSnapshot,
34 DiagnosticLastChange, DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters,
35 DiagnosticSubscription, FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult,
36 LocalDataPurgeTarget, LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation,
37 PresencePeer, PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot,
38 QueryValue, RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
39 RetainedUniqueConflict, RowState, SchemaFloor, SnapshotRead, SubscriptionCatchup,
40 SubscriptionRequest, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport,
41 SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage, WindowState,
42 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, is_canonical_bytes_envelope, json_to_column_value,
59 json_to_scope_map, normalize_values_casing, render_row_id_json, scope_map_to_json,
60 sort_scope_map,
61};
62
63const DEFAULT_ACCEPT: u8 = 0b0111;
67const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
68const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
69const ACCEPT_SQLITE: u8 = 1 << 2;
70const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
71const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
72
73pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
75const LOCAL_REVISION_KEY: &str = "localRevision";
76const CLIENT_ID_KEY: &str = "clientId";
77const LEASE_STATE_KEY: &str = "leaseState";
78const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
79const LOG_EPOCH_KEY: &str = "logEpoch";
80const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
85const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
86const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
87const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
90const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
93const INVALID_REQUEST_CODE: &str = "sync.invalid_request:";
94const LEGACY_VALUE_REFUSAL_MESSAGE: &str =
96 "the persisted commit carries values the current codec refuses";
97const INVALID_STORED_VALUES_REASON: &str = "invalid_stored_values";
99
100#[derive(Debug, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase", deny_unknown_fields)]
102struct PersistedLocalDataRebootstrapReceipt {
103 version: u8,
104 retained_commits: u64,
105 reset_subscriptions: u64,
106}
107
108fn invalid_local_rebootstrap_receipt() -> String {
109 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
110}
111
112fn encode_local_rebootstrap_receipt(
113 retained_commits: usize,
114 reset_subscriptions: usize,
115) -> Result<String, String> {
116 let retained_commits =
117 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
118 let reset_subscriptions =
119 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
120 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
121 return Err(invalid_local_rebootstrap_receipt());
122 }
123 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
124 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
125 retained_commits,
126 reset_subscriptions,
127 })
128 .map_err(|_| invalid_local_rebootstrap_receipt())
129}
130
131fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
132 if value == "v1" {
134 return Ok((0, 0));
135 }
136 let receipt: PersistedLocalDataRebootstrapReceipt =
137 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
138 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
139 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
140 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
141 {
142 return Err(invalid_local_rebootstrap_receipt());
143 }
144 Ok((
145 usize::try_from(receipt.retained_commits)
146 .map_err(|_| invalid_local_rebootstrap_receipt())?,
147 usize::try_from(receipt.reset_subscriptions)
148 .map_err(|_| invalid_local_rebootstrap_receipt())?,
149 ))
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153enum SubState {
154 Active,
155 Revoked,
156 Failed,
157}
158
159#[cfg(test)]
160mod observation_tests {
161 use super::*;
162 use crate::native_transport::HostTransport;
163 use crate::values::encode_row_json;
164 use serde_json::json;
165
166 #[test]
167 fn authority_policy_refuses_encrypted_and_undeclared_columns_before_open() {
168 let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
169 {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "secret", "type": "bytes", "encrypted": true, "declaredType": "string", "nullable": true}
170 ]}]});
171 for name in [
172 "secret",
173 "missing",
174 "*",
175 "id FROM authority; DELETE FROM authority",
176 "_syncular_version",
177 ] {
178 let limits = ClientLimits {
179 authority_reads: vec![crate::api::AuthorityReadDeclaration {
180 table: "authority".into(),
181 columns: vec!["id".into(), "actor_id".into(), name.into()],
182 scopes: [("actor_id".into(), vec!["a".into()])].into(),
183 }],
184 ..Default::default()
185 };
186 let error = SyncClient::new("authority".into(), &schema, limits)
187 .err()
188 .expect("forbidden policy");
189 assert!(
190 error.starts_with("client.authority_read_forbidden:"),
191 "{error}"
192 );
193 }
194 }
195
196 #[test]
197 fn authority_snapshot_observes_one_file_transaction_during_concurrent_writes() {
198 let path = std::env::temp_dir().join(format!(
199 "syncular-authority-snapshot-{}.db",
200 uuid::Uuid::new_v4()
201 ));
202 let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
203 {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "revision", "type": "integer", "nullable": false}
204 ]}]});
205 let limits = ClientLimits {
206 authority_reads: vec![crate::api::AuthorityReadDeclaration {
207 table: "authority".into(),
208 columns: vec!["id".into(), "actor_id".into(), "revision".into()],
209 scopes: [("actor_id".into(), vec!["a".into()])].into(),
210 }],
211 ..Default::default()
212 };
213 let mut client =
214 SyncClient::open_path("authority".into(), &schema, limits, path.to_str().unwrap())
215 .unwrap();
216 client.begin_security_preflight();
217 let update = |conn: &Connection, revision: i64| {
218 conn.execute_batch("BEGIN IMMEDIATE").unwrap();
219 conn.execute(
220 "INSERT OR REPLACE INTO _syncular_base_authority VALUES('one','a',?1,1)",
221 [revision],
222 )
223 .unwrap();
224 conn.execute(
225 "UPDATE _syncular_meta SET value=?1 WHERE key='localRevision'",
226 [revision.to_string()],
227 )
228 .unwrap();
229 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();
230 conn.execute_batch("COMMIT").unwrap();
231 };
232 update(&client.conn, 1);
233 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
234 let writer_barrier = barrier.clone();
235 let writer_path = path.clone();
236 let writer = std::thread::spawn(move || {
237 let conn = Connection::open(writer_path).unwrap();
238 conn.busy_timeout(std::time::Duration::from_secs(5))
239 .unwrap();
240 writer_barrier.wait();
241 for revision in 2..=100 {
242 update(&conn, revision);
243 }
244 });
245 barrier.wait();
246 for _ in 0..100 {
247 let snapshot = client.authority_snapshot().unwrap();
248 let revision = snapshot["revision"]
249 .as_str()
250 .unwrap()
251 .parse::<i64>()
252 .unwrap();
253 assert_eq!(
254 snapshot["tables"][0]["rows"][0]["values"]["revision"],
255 revision
256 );
257 assert_eq!(snapshot["tables"][0]["persisted"][0]["cursor"], revision);
258 assert_eq!(snapshot["complete"], true);
259 }
260 writer.join().unwrap();
261 assert_eq!(client.authority_snapshot().unwrap()["revision"], "100");
262 drop(client);
263 let _ = std::fs::remove_file(path);
264 }
265
266 fn client() -> SyncClient {
267 SyncClient::new(
268 "retry-test".to_owned(),
269 &json!({
270 "version": 1,
271 "tables": [{
272 "name": "tasks",
273 "primaryKey": "id",
274 "columns": [
275 { "name": "id", "type": "string", "nullable": false },
276 { "name": "project_id", "type": "string", "nullable": false }
277 ],
278 "scopes": [{ "pattern": "project:{project_id}" }]
279 }]
280 }),
281 ClientLimits::default(),
282 )
283 .expect("test client")
284 }
285
286 #[test]
287 fn subscribe_many_registers_all_or_nothing() {
288 let request = |id: &str, project: &str| SubscriptionRequest {
289 id: id.to_owned(),
290 table: "tasks".to_owned(),
291 scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
292 params: None,
293 };
294 let mut client = client();
295 client
296 .subscribe_many(vec![request("a", "p1"), request("b", "p2")])
297 .unwrap();
298 let persisted = |client: &SyncClient| -> i64 {
299 client
300 .conn
301 .query_row("SELECT count(*) FROM _syncular_subscriptions", [], |row| {
302 row.get(0)
303 })
304 .unwrap()
305 };
306 assert_eq!(persisted(&client), 2);
307 let error = client
309 .subscribe_many(vec![request("c", "p3"), request("b", "p9")])
310 .unwrap_err();
311 assert!(error.starts_with("client.subscription_intent_mismatch:"));
312 assert!(client.subscription_state("c").is_none());
313 assert_eq!(persisted(&client), 2);
314 assert!(client.conn.is_autocommit());
315 }
316
317 #[cfg(feature = "e2ee")]
318 #[test]
319 fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
320 let schema = json!({
321 "version": 1,
322 "tables": [{
323 "name": "secrets",
324 "primaryKey": "id",
325 "columns": [
326 { "name": "id", "type": "string", "nullable": false },
327 { "name": "encryption_key_id", "type": "string", "nullable": true },
328 { "name": "note", "type": "bytes", "nullable": true,
329 "encrypted": true, "declaredType": "string" }
330 ],
331 "scopes": []
332 }]
333 });
334 let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
335 .expect("test client");
336 let mut config = crate::values::EncryptionConfig::default();
337 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
338 config
339 .key_id_columns
340 .insert("secrets".to_owned(), "encryption_key_id".to_owned());
341 client.set_encryption(config);
342 client
345 .write_base_row(
346 "secrets",
347 &vec![Some(ColumnValue::String("ghost".into())), None, None],
348 1,
349 )
350 .unwrap();
351 client.rebuild_overlay().expect("rebuild overlay");
352 let commit_id = client
353 .patch(
354 "secrets",
355 "ghost",
356 Map::from_iter([("note".to_owned(), json!("x"))]),
357 None,
358 )
359 .expect("patch records without an author-time encrypt failure");
360 assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
361 assert!(client.drop_unencodable_outbox(true, None).unwrap());
363 assert!(client.pending_commit_ids().is_empty());
364 let rejections = client.rejections();
365 assert_eq!(rejections.len(), 1);
366 assert_eq!(rejections[0].code, "client.encrypt_failed");
367 assert_eq!(rejections[0].client_commit_id, commit_id);
368 assert_eq!(
369 client
370 .commit_outcome(&commit_id)
371 .unwrap()
372 .map(|outcome| outcome.status),
373 Some(CommitOutcomeStatus::Rejected)
374 );
375 }
376
377 #[test]
378 fn patch_scope_read_preserves_sqlite_failure() {
379 let mut client = client();
380 client
381 .conn
382 .execute_batch(
383 "DROP TABLE tasks;
384 CREATE VIEW tasks AS SELECT 't1' AS id, abs(-9223372036854775808) AS project_id;",
385 )
386 .unwrap();
387 let error = client
388 .patch(
389 "tasks",
390 "t1",
391 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
392 None,
393 )
394 .expect_err("the scope read must fail");
395 assert_ne!(error.code, "sync.invalid_request");
396 assert_eq!(error.code, "client.failed");
397 assert!(error.details.as_ref().unwrap()["legacyCause"]
398 .as_str()
399 .unwrap()
400 .contains("integer overflow"));
401 assert!(client.pending_commit_ids().is_empty());
402 assert!(client.conn.is_autocommit());
403 }
404
405 #[test]
406 fn patch_with_a_scope_column_matches_the_stored_local_row() {
407 let mut client = client();
408 client.create_synced_tables().unwrap();
409 client
410 .conn
411 .execute(
412 "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
413 [],
414 )
415 .unwrap();
416
417 client
420 .patch(
421 "tasks",
422 "t1",
423 Map::from_iter([
424 ("project_id".to_owned(), json!("p1")),
425 ("id".to_owned(), json!("t1")),
426 ]),
427 None,
428 )
429 .expect("an equal scope column is a no-op");
430 let values = client.outbox[0].ops[0]
431 .values
432 .clone()
433 .expect("the patch records values");
434 assert_eq!(values.get("id"), Some(&json!("t1")));
435 assert!(!values.contains_key("project_id"));
436
437 let differing = client.patch(
439 "tasks",
440 "t1",
441 Map::from_iter([("project_id".to_owned(), json!("p2"))]),
442 None,
443 );
444 let differing = differing.expect_err("a differing scope column is rejected");
445 assert_eq!(differing.code, "sync.invalid_request");
446 assert_eq!(differing.message, "the authoring request is invalid");
447 assert!(
448 differing
449 .details
450 .as_ref()
451 .and_then(|details| details["legacyCause"].as_str())
452 .is_some_and(|cause| cause.contains("patch cannot write scope column")),
453 "{differing:?}"
454 );
455
456 let absent = client.patch(
459 "tasks",
460 "ghost",
461 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
462 None,
463 );
464 let absent = absent.expect_err("an absent local row is rejected");
465 assert_eq!(absent.code, "sync.row_missing");
466 assert!(absent.message.contains("requires a local row"));
467 assert_eq!(client.pending_commit_ids().len(), 1);
468 }
469
470 #[test]
471 fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
472 let schema = json!({
473 "version": 1,
474 "tables": [
475 {
476 "name": "tenants",
477 "primaryKey": "tenant_id",
478 "columns": [
479 { "name": "tenant_id", "type": "string", "nullable": false },
480 { "name": "body", "type": "string", "nullable": false }
481 ],
482 "scopes": [{ "pattern": "tenant:{tenant_id}" }]
483 },
484 {
485 "name": "buckets",
486 "primaryKey": "id",
487 "columns": [
488 { "name": "id", "type": "string", "nullable": false },
489 { "name": "bucket", "type": "float", "nullable": false },
490 { "name": "body", "type": "string", "nullable": false }
491 ],
492 "scopes": [{ "pattern": "bucket:{bucket}" }]
493 }
494 ]
495 });
496 let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
497 .expect("test client");
498 client.create_synced_tables().unwrap();
499
500 client
502 .conn
503 .execute(
504 "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
505 [],
506 )
507 .unwrap();
508 client
509 .patch(
510 "tenants",
511 "t1",
512 Map::from_iter([
513 ("tenant_id".to_owned(), json!("t1")),
514 ("body".to_owned(), json!("created")),
515 ]),
516 None,
517 )
518 .expect("the primary-key scope column is proven equal by construction");
519
520 client
522 .conn
523 .execute(
524 "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
525 [],
526 )
527 .unwrap();
528 client
529 .patch(
530 "buckets",
531 "b1",
532 Map::from_iter([
533 ("bucket".to_owned(), json!(2)),
534 ("body".to_owned(), json!("coerced")),
535 ]),
536 None,
537 )
538 .expect("the stored REAL value coerces from a supplied integer");
539 let values = client.outbox[1].ops[0]
540 .values
541 .clone()
542 .expect("the patch records values");
543 assert!(!values.contains_key("bucket"));
544
545 let differing = client.patch(
547 "buckets",
548 "b1",
549 Map::from_iter([("bucket".to_owned(), json!(3))]),
550 None,
551 );
552 let differing = differing.expect_err("a differing scope value is rejected");
553 assert_eq!(differing.code, "sync.invalid_request");
554 assert_eq!(differing.message, "the authoring request is invalid");
555 assert!(
556 differing
557 .details
558 .as_ref()
559 .and_then(|details| details["legacyCause"].as_str())
560 .is_some_and(|cause| cause.contains("patch cannot write scope column")),
561 "{differing:?}"
562 );
563 }
564
565 #[cfg(feature = "e2ee")]
566 #[test]
567 fn stored_key_fallback_resolves_an_integer_primary_key() {
568 let schema = json!({
569 "version": 1,
570 "tables": [{
571 "name": "patients",
572 "primaryKey": "id",
573 "columns": [
574 { "name": "id", "type": "integer", "nullable": false },
575 { "name": "encryption_key_id", "type": "string", "nullable": true },
576 { "name": "note", "type": "bytes", "nullable": true,
577 "encrypted": true, "declaredType": "string" }
578 ],
579 "scopes": []
580 }]
581 });
582 let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
583 .expect("test client");
584 client.create_synced_tables().unwrap();
585 client
586 .conn
587 .execute(
588 "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
589 VALUES (5, 'k1', NULL, 1)",
590 [],
591 )
592 .unwrap();
593 let mut config = crate::values::EncryptionConfig::default();
594 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
595 config
596 .key_id_columns
597 .insert("patients".to_owned(), "encryption_key_id".to_owned());
598 client.set_encryption(config);
599 let table = client.schema.table("patients").unwrap();
600 let fallback = client
601 .stored_key_fallback(table, "5")
602 .expect("the stored integer key resolves the fallback");
603 assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
604 }
605
606 #[test]
607 fn patch_types_an_integer_primary_key_from_its_canonical_row_id() {
608 let schema = json!({
609 "version": 1,
610 "tables": [{
611 "name": "counters",
612 "primaryKey": "id",
613 "columns": [
614 { "name": "id", "type": "integer", "nullable": false },
615 { "name": "title", "type": "string", "nullable": false }
616 ],
617 "scopes": []
618 }]
619 });
620 let mut client = SyncClient::new("int-patch".into(), &schema, ClientLimits::default())
621 .expect("test client");
622 client
623 .write_base_row(
624 "counters",
625 &vec![
626 Some(ColumnValue::Integer(42)),
627 Some(ColumnValue::String("before".into())),
628 ],
629 1,
630 )
631 .unwrap();
632 client.rebuild_overlay().expect("rebuild overlay");
633 client
634 .patch(
635 "counters",
636 "42",
637 Map::from_iter([
638 ("id".to_owned(), json!(42)),
639 ("title".to_owned(), json!("after")),
640 ]),
641 None,
642 )
643 .expect("an integer key that renders as the rowId is not a change");
644 let rows = client.query("SELECT id, title FROM counters", &[]).unwrap();
645 assert_eq!(Value::from(rows), json!([{"id": 42, "title": "after"}]));
646 for row_id in ["042", "+42", "42.0"] {
647 let error = client
648 .patch(
649 "counters",
650 row_id,
651 Map::from_iter([("title".to_owned(), json!("x"))]),
652 None,
653 )
654 .expect_err("a non-canonical integer rowId names no row");
655 assert_eq!(error.code, "sync.row_missing", "{row_id}");
656 }
657 }
658
659 #[test]
660 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
661 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
662 {"name":"id","type":"string","nullable":false},
663 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
664 for state in ["absent", "cached", "pinned"] {
665 let path = std::env::temp_dir().join(format!(
666 "syncular-blob-stage-{}.sqlite",
667 uuid::Uuid::new_v4()
668 ));
669 let mut client = SyncClient::open_path(
670 "stage-test".into(),
671 &schema,
672 Default::default(),
673 path.to_str().unwrap(),
674 )
675 .unwrap();
676 let bytes = b"atomic stage";
677 if state != "absent" {
678 client
679 .upload_blob(bytes, Some("text/plain".into()), None)
680 .unwrap();
681 if state == "cached" {
682 client
683 .conn
684 .execute("DELETE FROM _syncular_blob_uploads", [])
685 .unwrap();
686 }
687 }
688 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
689 client
690 .conn
691 .execute_batch(
692 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
693 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
694 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
695 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
696 )
697 .unwrap();
698 let error = client
699 .upload_blob(bytes, Some("application/octet-stream".into()), None)
700 .unwrap_err();
701 assert!(error.contains("injected stage failure"), "{state}: {error}");
702 assert!(client.conn.is_autocommit());
703 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
704 drop(client);
705 let mut reopened = SyncClient::open_path(
706 "stage-test".into(),
707 &schema,
708 Default::default(),
709 path.to_str().unwrap(),
710 )
711 .unwrap();
712 let durable = reopened
713 .query("SELECT * FROM _syncular_blobs", &[])
714 .unwrap();
715 reopened
716 .conn
717 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
718 .unwrap();
719 let retried = reopened.upload_blob(bytes, None, None).unwrap();
720 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
721 assert_eq!(
722 reopened
723 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
724 .unwrap(),
725 vec![Map::from_iter([(
726 "blob_id".into(),
727 Value::from(blob_id_for(bytes)),
728 )])]
729 );
730 drop(reopened);
731 std::fs::remove_file(&path).unwrap();
732 assert_eq!(durable, bodies, "{state}: reopened body state");
733 assert_eq!(immediate, bodies, "{state}: immediate body state");
734 }
735 }
736
737 #[test]
738 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
739 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
740 {"name":"id","type":"string","nullable":false},
741 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
742 for fault in [
743 "missing",
744 "body-type",
745 "length",
746 "hash",
747 "pin-metadata",
748 "read",
749 "pin-delete",
750 ] {
751 let path = std::env::temp_dir().join(format!(
752 "syncular-blob-upload-{}.sqlite",
753 uuid::Uuid::new_v4()
754 ));
755 let mut client = SyncClient::open_path(
756 "upload-test".into(),
757 &schema,
758 Default::default(),
759 path.to_str().unwrap(),
760 )
761 .unwrap();
762 let bytes = b"durable upload";
763 let ref_value = client
764 .upload_blob(bytes, Some("text/plain".into()), None)
765 .unwrap();
766 let commit = client
767 .mutate(vec![Mutation::Upsert {
768 table: "attachments".into(),
769 values: Map::from_iter([
770 ("id".into(), Value::from("pending")),
771 (
772 "file".into(),
773 Value::from(format!(
774 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
775 blob_id_for(bytes),
776 bytes.len()
777 )),
778 ),
779 ]),
780 base_version: None,
781 }])
782 .unwrap();
783 client
784 .conn
785 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
786 .unwrap();
787 client.conn.execute_batch(match fault {
788 "missing" => "DELETE FROM _syncular_blobs",
789 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
790 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
791 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
792 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
793 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
794 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
795 }).unwrap();
796 let state = if fault == "read" {
797 Vec::new()
798 } else {
799 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
800 };
801 let mut transport = CountingRealtimeTransport::default();
802 let error = client.flush_blob_uploads(&mut transport).unwrap_err();
803 assert_eq!(
804 error.code,
805 if matches!(fault, "read" | "pin-delete") {
806 "client.failed"
807 } else {
808 "sync.local_corrupt"
809 },
810 "{fault}"
811 );
812 assert_eq!(
813 transport.blob_uploads.len(),
814 usize::from(fault == "pin-delete"),
815 "{fault}: corrupt bytes must never be uploaded"
816 );
817 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
818 if fault != "read" {
819 assert_eq!(
820 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
821 state
822 );
823 } else {
824 client
825 .conn
826 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
827 .unwrap();
828 }
829 drop(client);
830 let mut reopened = SyncClient::open_path(
831 "upload-test".into(),
832 &schema,
833 Default::default(),
834 path.to_str().unwrap(),
835 )
836 .unwrap();
837 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
838 if fault != "read" {
839 assert_eq!(
840 reopened
841 .query("SELECT * FROM _syncular_blobs", &[])
842 .unwrap(),
843 state
844 );
845 }
846 if fault == "pin-delete" {
847 reopened
848 .conn
849 .execute_batch("DROP TRIGGER fail_pin_delete")
850 .unwrap();
851 }
852 if fault == "pin-metadata" {
853 reopened
854 .conn
855 .execute(
856 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
857 [],
858 )
859 .unwrap();
860 }
861 reopened
862 .conn
863 .execute_batch(
864 "DELETE FROM _syncular_blobs;
865 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
866 )
867 .unwrap();
868 transport.blob_uploads.clear();
869 reopened.flush_blob_uploads(&mut transport).unwrap();
870 assert_eq!(
871 transport.blob_uploads,
872 [(blob_id_for(bytes), bytes.to_vec())]
873 );
874 assert_eq!(
875 reopened
876 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
877 .unwrap(),
878 Vec::<Map<String, Value>>::new()
879 );
880 assert_eq!(reopened.pending_commit_ids(), [commit]);
881 assert_eq!(
882 reopened
883 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
884 .unwrap()
885 .unwrap()
886 .bytes,
887 bytes
888 );
889 drop(reopened);
890 std::fs::remove_file(path).unwrap();
891 }
892 }
893
894 fn coalesced_fixture(sections: usize, commits: usize, rows: usize) -> (SyncClient, Message) {
898 let mut client = client();
899 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
900 let mut response = Message {
901 wire_version: WIRE_VERSION,
902 msg_kind: MsgKind::Response,
903 frames: vec![Frame::RespHeader {
904 required_schema_version: None,
905 latest_schema_version: None,
906 log_epoch: Some("epoch-1".into()),
907 reset_required: Some(false),
908 }],
909 };
910 let mut seq = 0;
911 for section in 0..sections {
912 let project = format!("p{section}");
913 let scopes = vec![("project_id".to_owned(), vec![project.clone()])];
914 client
915 .subscribe(format!("s{section}"), "tasks".into(), scopes.clone(), None)
916 .unwrap();
917 let sub = client.subs.last_mut().unwrap();
918 sub.cursor = 0;
919 sub.synced_once = true;
920 sub.effective = Some(scopes.clone());
921 let sub = sub.clone();
922 client.persist_sub(&sub).unwrap();
923 response.frames.push(Frame::SubStart {
924 id: format!("s{section}"),
925 status: SubStatus::Active,
926 reason_code: "".into(),
927 effective_scopes: scopes,
928 bootstrap: false,
929 });
930 for _ in 0..commits {
931 seq += 1;
932 let changes = (0..rows)
933 .map(|row| {
934 let id = format!("{project}-{seq}-{row}");
935 let values = Map::from_iter([
936 ("id".into(), Value::from(id.clone())),
937 ("project_id".into(), Value::from(project.clone())),
938 ]);
939 ssp2::model::Change {
940 table_index: 0,
941 row_id: id.clone(),
942 op: Op::Upsert,
943 row_version: Some(seq),
944 scopes: vec![("project_id".into(), project.clone())],
945 row: Some(
946 encode_row_json(
947 client.schema.table("tasks").unwrap(),
948 &id,
949 &values,
950 &client.encryption,
951 )
952 .unwrap(),
953 ),
954 }
955 })
956 .collect();
957 response.frames.push(Frame::Commit {
958 commit_seq: seq,
959 actor_id: "writer".into(),
960 created_at_ms: 1,
961 tables: vec!["tasks".into()],
962 changes,
963 });
964 }
965 response.frames.push(Frame::SubEnd {
966 next_cursor: seq,
967 bootstrap_state: None,
968 });
969 }
970 client.drain_change_batches();
971 (client, response)
972 }
973
974 fn count_commits(client: &SyncClient) -> std::sync::Arc<std::sync::atomic::AtomicUsize> {
975 let commits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
976 let counter = commits.clone();
977 client
978 .conn
979 .commit_hook(Some(move || {
980 counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
981 false
982 }))
983 .expect("commit hook");
984 commits
985 }
986
987 #[test]
988 fn a_response_of_many_small_sections_commits_once() {
989 for realtime in [false, true] {
990 let (mut client, response) = coalesced_fixture(70, 1, 1);
991 client.realtime_state = RealtimeState::Connected;
992 client.realtime_reason_code = None;
993 let mut transport = CountingRealtimeTransport::default();
994 let (_, meta) = client.build_request(false).unwrap();
995 let commits = count_commits(&client);
996 if realtime {
997 client.on_realtime_binary(&mut transport, &encode_message(&response));
998 } else {
999 assert!(matches!(
1000 client.process_response(&mut transport, response, &meta),
1001 SyncOutcome::Ok(_)
1002 ));
1003 }
1004 assert_eq!(
1005 commits.load(std::sync::atomic::Ordering::SeqCst),
1006 1,
1007 "{realtime}"
1008 );
1009 assert!(client.conn.is_autocommit());
1010 assert!(client.subs.iter().all(|sub| sub.cursor > 0));
1011 let revisions = client
1013 .drain_change_batches()
1014 .into_iter()
1015 .filter(|batch| !batch.tables.is_empty())
1016 .map(|batch| batch.revision)
1017 .collect::<Vec<_>>();
1018 assert_eq!(
1019 revisions,
1020 (1..=70).map(|r: u64| r.to_string()).collect::<Vec<_>>()
1021 );
1022 assert_eq!(client.query("SELECT id FROM tasks", &[]).unwrap().len(), 70);
1023 assert_eq!(
1025 transport
1026 .messages
1027 .iter()
1028 .filter(|m| m.contains("\"ack\""))
1029 .count(),
1030 1,
1031 "{realtime}"
1032 );
1033 }
1034 }
1035
1036 #[test]
1037 fn a_response_past_the_row_bound_commits_per_bound() {
1038 let (mut client, response) = coalesced_fixture(1, 3, 600);
1040 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1041 let (_, meta) = client.build_request(false).unwrap();
1042 let commits = count_commits(&client);
1043 assert!(matches!(
1044 client.process_response(&mut transport, response, &meta),
1045 SyncOutcome::Ok(_)
1046 ));
1047 assert_eq!(commits.load(std::sync::atomic::Ordering::SeqCst), 3);
1048 assert_eq!(
1049 client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1050 1800
1051 );
1052 assert_eq!(client.subs[0].cursor, 3);
1053 }
1054
1055 #[test]
1056 fn a_failed_coalesced_commit_restores_memory_and_publishes_nothing() {
1057 let (mut client, response) = coalesced_fixture(2, 2, 600);
1061 client
1062 .conn
1063 .execute_batch(
1064 "PRAGMA foreign_keys = ON; CREATE TABLE fail_parent(id INTEGER PRIMARY KEY); CREATE TABLE fail_child(id INTEGER REFERENCES fail_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_commit AFTER INSERT ON _syncular_base_tasks WHEN NEW.id = 'p0-1-0' BEGIN INSERT INTO fail_child VALUES(1); END",
1065 )
1066 .unwrap();
1067 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1068 let (_, meta) = client.build_request(false).unwrap();
1069 let subs = client.subs.clone();
1070 let revision = client.local_revision();
1071 let outcome = client.process_response(&mut transport, response.clone(), &meta);
1072 assert!(
1073 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == "storage.failed"),
1074 "{outcome:?}"
1075 );
1076 assert!(client.conn.is_autocommit());
1077 assert!(client.coalesced.is_none() && client.coalesced_failure.is_none());
1078 assert_eq!(client.local_revision(), revision);
1079 assert!(client
1080 .drain_change_batches()
1081 .iter()
1082 .all(|batch| batch.tables.is_empty()));
1083 assert!(client
1084 .query("SELECT id FROM tasks", &[])
1085 .unwrap()
1086 .is_empty());
1087 assert_eq!(
1088 client.subs.iter().map(|s| s.cursor).collect::<Vec<_>>(),
1089 subs.iter().map(|s| s.cursor).collect::<Vec<_>>()
1090 );
1091 client
1093 .conn
1094 .execute_batch("DROP TRIGGER fail_commit")
1095 .unwrap();
1096 let (_, meta) = client.build_request(false).unwrap();
1097 assert!(matches!(
1098 client.process_response(&mut transport, response, &meta),
1099 SyncOutcome::Ok(_)
1100 ));
1101 assert_eq!(
1102 client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1103 2400
1104 );
1105 }
1106
1107 #[test]
1108 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
1109 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1110 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
1111 "scopes":[{"pattern":"project:{project_id}"}]}]});
1112 for realtime in [false, true] {
1113 for fault in [
1114 "none",
1115 "row",
1116 "revision",
1117 "commit",
1118 "cursor",
1119 "error",
1120 "missing-end",
1121 ] {
1122 if realtime && fault == "missing-end" {
1123 continue;
1124 }
1125 let path = std::env::temp_dir()
1126 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
1127 let mut client = SyncClient::open_path(
1128 "frame-test".into(),
1129 &schema,
1130 Default::default(),
1131 path.to_str().unwrap(),
1132 )
1133 .unwrap();
1134 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1135 client
1136 .subscribe(
1137 "tasks".into(),
1138 "tasks".into(),
1139 vec![("project_id".into(), vec!["p1".into()])],
1140 None,
1141 )
1142 .unwrap();
1143 client.subs[0].cursor = 0;
1144 client.subs[0].synced_once = true;
1145 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1146 client.persist_sub(&client.subs[0]).unwrap();
1147 client.realtime_state = RealtimeState::Connected;
1148 client.realtime_reason_code = None;
1149 client.drain_change_batches();
1150 let mut transport = CountingRealtimeTransport::default();
1151 let (_, meta) = client.build_request(false).unwrap();
1152 let mut response = Message {
1153 wire_version: WIRE_VERSION,
1154 msg_kind: MsgKind::Response,
1155 frames: vec![
1156 Frame::RespHeader {
1157 required_schema_version: None,
1158 latest_schema_version: None,
1159 log_epoch: Some("epoch-1".into()),
1160 reset_required: Some(false),
1161 },
1162 Frame::SubStart {
1163 id: "tasks".into(),
1164 status: SubStatus::Active,
1165 reason_code: "".into(),
1166 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1167 bootstrap: false,
1168 },
1169 ],
1170 };
1171 for (index, id) in ["first", "second"].iter().enumerate() {
1172 let values = Map::from_iter([
1173 ("id".into(), Value::from(*id)),
1174 ("project_id".into(), Value::from("p1")),
1175 ]);
1176 response.frames.push(Frame::Commit {
1177 commit_seq: index as i64 + 1,
1178 actor_id: "writer".into(),
1179 created_at_ms: 1,
1180 tables: vec!["tasks".into()],
1181 changes: vec![ssp2::model::Change {
1182 table_index: 0,
1183 row_id: (*id).into(),
1184 op: Op::Upsert,
1185 row_version: Some(index as i64 + 1),
1186 scopes: vec![("project_id".into(), "p1".into())],
1187 row: Some(
1188 encode_row_json(
1189 client.schema.table("tasks").unwrap(),
1190 id,
1191 &values,
1192 &client.encryption,
1193 )
1194 .unwrap(),
1195 ),
1196 }],
1197 });
1198 }
1199 response.frames.push(Frame::SubEnd {
1200 next_cursor: 2,
1201 bootstrap_state: None,
1202 });
1203 let valid = response.clone();
1204 match fault {
1205 "row" => {
1206 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
1207 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
1208 },
1209 "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(),
1210 "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(),
1211 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
1212 "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});},
1213 "missing-end" => {response.frames.truncate(3);},
1214 _ => {}
1215 }
1216 if realtime {
1217 client.on_realtime_binary(&mut transport, &encode_message(&response));
1218 } else {
1219 assert_eq!(
1220 matches!(
1221 client.process_response(&mut transport, response, &meta),
1222 SyncOutcome::Ok(_)
1223 ),
1224 fault == "none",
1225 "{fault}"
1226 );
1227 }
1228 assert!(
1229 client.conn.is_autocommit(),
1230 "no open transaction after {realtime}/{fault}"
1231 );
1232 let count = match fault {
1236 "none" | "cursor" => 2,
1237 "commit" => 0,
1238 _ => 1,
1239 };
1240 let expected_revision = count + u64::from(realtime && fault != "none");
1241 assert_eq!(
1242 client.local_revision(),
1243 expected_revision,
1244 "{realtime}/{fault}"
1245 );
1246 let batches = client.drain_change_batches();
1247 assert_eq!(
1248 batches.len(),
1249 expected_revision as usize,
1250 "{realtime}/{fault}"
1251 );
1252 let row_batches: Vec<_> = batches
1253 .iter()
1254 .filter(|batch| !batch.tables.is_empty())
1255 .collect();
1256 assert_eq!(row_batches.len(), count as usize);
1257 assert_eq!(
1258 row_batches
1259 .iter()
1260 .map(|b| b.revision.clone())
1261 .collect::<Vec<_>>(),
1262 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
1263 );
1264 for table in ["tasks", "_syncular_base_tasks"] {
1265 assert_eq!(
1266 client
1267 .conn
1268 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
1269 .get::<_, u64>(0))
1270 .unwrap(),
1271 count,
1272 "{realtime}/{fault}/{table}"
1273 );
1274 }
1275 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1276 assert_eq!(
1277 transport
1278 .messages
1279 .iter()
1280 .filter(|m| m.contains("\"ack\""))
1281 .count(),
1282 usize::from(fault == "none")
1283 );
1284 if matches!(fault, "revision" | "commit" | "cursor") {
1285 client
1286 .conn
1287 .execute_batch("DROP TRIGGER fail_frame")
1288 .unwrap();
1289 }
1290 drop(client);
1291 let mut reopened = SyncClient::open_path(
1292 "frame-test".into(),
1293 &schema,
1294 Default::default(),
1295 path.to_str().unwrap(),
1296 )
1297 .unwrap();
1298 assert_eq!(reopened.local_revision(), expected_revision);
1299 assert_eq!(
1300 reopened
1301 .query("SELECT id FROM tasks ORDER BY id", &[])
1302 .unwrap()
1303 .len(),
1304 count as usize
1305 );
1306 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1307 let (_, meta) = reopened.build_request(false).unwrap();
1308 assert!(matches!(
1309 reopened.process_response(&mut transport, valid, &meta),
1310 SyncOutcome::Ok(_)
1311 ));
1312 assert_eq!(
1313 reopened
1314 .query("SELECT id FROM tasks ORDER BY id", &[])
1315 .unwrap()
1316 .len(),
1317 2
1318 );
1319 assert_eq!(reopened.subs[0].cursor, 2);
1320 drop(reopened);
1321 std::fs::remove_file(path).unwrap();
1322 }
1323 }
1324 }
1325
1326 #[test]
1327 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
1328 for status in [SubStatus::Reset, SubStatus::Revoked] {
1329 for fault in ["subscription", "revision"] {
1330 let mut client = client();
1331 client
1332 .subscribe(
1333 "tasks".into(),
1334 "tasks".into(),
1335 vec![("project_id".into(), vec!["p1".into()])],
1336 None,
1337 )
1338 .unwrap();
1339 client.subs[0].cursor = 41;
1340 client.subs[0].synced_once = true;
1341 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1342 client.persist_sub(&client.subs[0]).unwrap();
1343 client
1344 .mutate(vec![Mutation::Upsert {
1345 table: "tasks".into(),
1346 values: Map::from_iter([
1347 ("id".into(), Value::from("pending")),
1348 ("project_id".into(), Value::from("p1")),
1349 ]),
1350 base_version: None,
1351 }])
1352 .unwrap();
1353 let ids = client.pending_commit_ids();
1354 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
1355 let revision = client.local_revision();
1356 client.drain_change_batches();
1357 let (_, meta) = client.build_request(false).unwrap();
1358 client.conn.execute_batch(if fault == "subscription" {
1359 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
1360 } else {
1361 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
1362 }).unwrap();
1363 let mut transport = CountingRealtimeTransport::default();
1364 let mut report = SyncReport::default();
1365 if status == SubStatus::Reset {
1367 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
1368 }
1369 let result = client.process_section(
1370 &mut transport,
1371 "tasks",
1372 status,
1373 "sync.scope_revoked",
1374 vec![],
1375 vec![],
1376 Some((42, None)),
1377 &meta,
1378 &mut report,
1379 );
1380 assert!(result.is_err(), "{status:?}/{fault}");
1381 assert!(client.conn.is_autocommit());
1382 assert_eq!(client.subs[0].cursor, 41);
1383 assert_eq!(client.subs[0].state, SubState::Active);
1384 assert_eq!(client.pending_commit_ids(), ids);
1385 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1386 assert!(client.rejections.is_empty());
1387 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
1388 assert_eq!(client.local_revision(), revision);
1389 assert!(client.drain_change_batches().is_empty());
1390 assert!(report.revoked.is_empty() && report.resets.is_empty());
1391 }
1392 }
1393 }
1394
1395 #[test]
1396 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
1397 let mut client = client();
1398 client
1399 .subscribe(
1400 "tasks".into(),
1401 "tasks".into(),
1402 vec![("project_id".into(), vec!["p1".into()])],
1403 None,
1404 )
1405 .unwrap();
1406 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1407 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
1408 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
1409 let row = |id: &str| ssp2::segment::SegmentRow {
1410 server_version: 1,
1411 values: vec![
1412 Some(ColumnValue::String(id.into())),
1413 Some(ColumnValue::String("p1".into())),
1414 ],
1415 };
1416 let segment = RowsSegment {
1417 table: "tasks".into(),
1418 schema_version: 1,
1419 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
1420 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
1421 };
1422 assert!(client.apply_segment(0, &segment, true).is_err());
1423 assert!(client.conn.is_autocommit());
1424 assert_eq!(
1425 client
1426 .query("SELECT id FROM tasks ORDER BY id", &[])
1427 .unwrap(),
1428 vec![Map::from_iter([("id".into(), Value::from("first"))])]
1429 );
1430 assert_eq!(client.local_revision(), 1);
1431 assert_eq!(client.drain_change_batches().len(), 1);
1432 assert_eq!(client.subs[0].cursor, -1);
1433 client
1434 .conn
1435 .execute_batch("DROP TRIGGER fail_block")
1436 .unwrap();
1437 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
1438 assert_eq!(client.local_revision(), 3);
1439 assert_eq!(client.drain_change_batches().len(), 2);
1440 }
1441
1442 struct SegmentBatchTransport {
1446 response: Message,
1447 bodies: BTreeMap<String, Vec<u8>>,
1448 batches: Vec<Vec<String>>,
1449 single_fetches: usize,
1450 }
1451
1452 impl Transport for SegmentBatchTransport {
1453 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1454 Ok(encode_message(&self.response))
1455 }
1456
1457 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1458 self.sync(request)
1459 }
1460
1461 fn download_segment(
1462 &mut self,
1463 _request: &SegmentRequest,
1464 _on_progress: &mut dyn FnMut(u64),
1465 ) -> Result<Vec<u8>, TransportError> {
1466 self.single_fetches += 1;
1467 Err(TransportError::new(
1468 "sync.transport_failed",
1469 "per-descriptor fetch",
1470 ))
1471 }
1472
1473 fn download_segments(
1474 &mut self,
1475 fetches: &[crate::SegmentFetch],
1476 on_progress: &mut (dyn FnMut(usize, u64) + Send),
1477 ) -> Vec<Result<Vec<u8>, TransportError>> {
1478 self.batches.push(
1479 fetches
1480 .iter()
1481 .map(|fetch| match fetch {
1482 crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
1483 crate::SegmentFetch::Url(url) => url.clone(),
1484 })
1485 .collect(),
1486 );
1487 fetches
1488 .iter()
1489 .enumerate()
1490 .map(|(index, fetch)| {
1491 let crate::SegmentFetch::Direct(request) = fetch else {
1492 panic!("the descriptors are direct-endpoint requests")
1493 };
1494 let bytes = self.bodies[&request.segment_id].clone();
1495 on_progress(index, bytes.len() as u64);
1496 Ok(bytes)
1497 })
1498 .collect()
1499 }
1500
1501 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1502 Ok(())
1503 }
1504
1505 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1506 Ok(())
1507 }
1508
1509 fn realtime_close(&mut self) -> Result<(), TransportError> {
1510 Ok(())
1511 }
1512 }
1513
1514 #[test]
1515 fn a_response_fetches_all_its_segment_pages_in_one_ordered_batch() {
1516 let mut client = client();
1517 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1518 client
1519 .subscribe(
1520 "tasks".into(),
1521 "tasks".into(),
1522 vec![("project_id".into(), vec!["p1".into()])],
1523 None,
1524 )
1525 .unwrap();
1526 client.subs[0].cursor = 0;
1527 client.subs[0].synced_once = true;
1528 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1529 client.persist_sub(&client.subs[0]).unwrap();
1530 client.drain_change_batches();
1531 let columns = client.schema.table("tasks").unwrap().wire_columns.clone();
1532
1533 let pages = ["one", "two", "three", "four"].map(|id| {
1537 let bytes = ssp2::segment::encode_rows_segment(&RowsSegment {
1538 table: "tasks".into(),
1539 schema_version: 1,
1540 columns: columns.clone(),
1541 blocks: vec![vec![ssp2::segment::SegmentRow {
1542 server_version: 1,
1543 values: vec![
1544 Some(ColumnValue::String(id.into())),
1545 Some(ColumnValue::String("p1".into())),
1546 ],
1547 }]],
1548 });
1549 (
1550 format!("sha256:{}", bytes_to_hex(&Sha256::digest(&bytes))),
1551 bytes,
1552 )
1553 });
1554
1555 let mut frames = vec![
1556 Frame::RespHeader {
1557 required_schema_version: None,
1558 latest_schema_version: None,
1559 log_epoch: Some("epoch-1".into()),
1560 reset_required: Some(false),
1561 },
1562 Frame::SubStart {
1563 id: "tasks".into(),
1564 status: SubStatus::Active,
1565 reason_code: String::new(),
1566 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1567 bootstrap: false,
1568 },
1569 ];
1570 for (segment_id, bytes) in &pages {
1571 frames.push(Frame::SegmentRef {
1572 segment_id: segment_id.clone(),
1573 media_type: MediaType::Rows,
1574 table: "tasks".into(),
1575 byte_length: bytes.len() as i64,
1576 row_count: 1,
1577 as_of_commit_seq: 1,
1578 scope_digest: "digest".into(),
1579 row_cursor: None,
1580 next_row_cursor: None,
1581 url: None,
1582 url_expires_at_ms: None,
1583 });
1584 }
1585 frames.push(Frame::SubEnd {
1586 next_cursor: 1,
1587 bootstrap_state: None,
1588 });
1589 let mut transport = SegmentBatchTransport {
1590 response: Message {
1591 wire_version: WIRE_VERSION,
1592 msg_kind: MsgKind::Response,
1593 frames,
1594 },
1595 bodies: pages
1596 .iter()
1597 .map(|(segment_id, bytes)| (segment_id.clone(), bytes.clone()))
1598 .collect(),
1599 batches: Vec::new(),
1600 single_fetches: 0,
1601 };
1602
1603 let outcome = client.sync(&mut transport);
1604 assert!(matches!(outcome, SyncOutcome::Ok(_)), "{outcome:?}");
1605 assert_eq!(
1606 transport.batches,
1607 vec![pages
1608 .iter()
1609 .map(|(segment_id, _)| segment_id.clone())
1610 .collect::<Vec<_>>()],
1611 "one batch per response, in wire order"
1612 );
1613 assert_eq!(
1614 transport.single_fetches, 0,
1615 "the batch seam replaces the per-descriptor calls"
1616 );
1617 assert_eq!(
1618 client
1619 .query("SELECT id FROM tasks ORDER BY id", &[])
1620 .unwrap(),
1621 ["four", "one", "three", "two"]
1622 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
1623 .to_vec()
1624 );
1625 assert_eq!(client.subs[0].cursor, 1);
1626 }
1627
1628 #[test]
1629 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1630 assert_eq!(
1631 client().diagnostics_storage().blob_cache_bytes_approx,
1632 Some(0)
1633 );
1634 let client = SyncClient::new(
1635 "diagnostic-storage".to_owned(),
1636 &json!({
1637 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1638 "columns": [{"name": "id", "type": "string", "nullable": false},
1639 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1640 }),
1641 ClientLimits {
1642 blob_cache_max_bytes: Some(5),
1643 ..Default::default()
1644 },
1645 )
1646 .unwrap();
1647 let empty = client.diagnostics_storage();
1648 assert_eq!(empty.status, "healthy");
1649 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1650 assert_eq!(empty.retained_outcome_entries, Some(0));
1651 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1652 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1653 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1654 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1655 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1656 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1657 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1658 let written = client.diagnostics_storage();
1659 assert_eq!(written.status, "pressure");
1660 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1661 assert_eq!(written.retained_outcome_entries, Some(2));
1662 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1663 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1664 assert_eq!(
1665 written.pressure_reason_code.as_deref(),
1666 Some("client.blob_cache_over_limit")
1667 );
1668 client
1669 .conn
1670 .execute_batch(
1671 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1672 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1673 )
1674 .unwrap();
1675 let staged = client.diagnostics_storage();
1676 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1677 assert_eq!(staged.retained_outcome_entries, Some(0));
1678 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1679 assert_eq!(staged.status, "healthy");
1680 client.conn.execute_batch("ROLLBACK").unwrap();
1681 assert_eq!(client.diagnostics_storage(), written);
1682 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1683 let unreadable = client.diagnostics_storage();
1684 assert_eq!(unreadable.status, "unreadable");
1685 assert!(unreadable.database_bytes_approx.is_none());
1686 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1687 assert!(unreadable.retained_outcome_entries.is_none());
1688 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1689 assert!(unreadable.blob_cache_bytes_approx.is_none());
1690 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1691 let restored = client.diagnostics_storage();
1692 assert_eq!(restored.status, written.status);
1693 assert_eq!(
1694 restored.retained_outcome_bytes_approx,
1695 written.retained_outcome_bytes_approx
1696 );
1697 client.conn.set_prepared_statement_cache_capacity(0);
1698 assert_eq!(client.diagnostics_storage(), restored);
1699 }
1700
1701 struct ServerErrorTransport {
1703 code: &'static str,
1704 retryable: bool,
1705 }
1706
1707 impl Transport for ServerErrorTransport {
1708 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1709 Ok(encode_message(&Message {
1710 wire_version: WIRE_VERSION,
1711 msg_kind: MsgKind::Response,
1712 frames: vec![
1713 Frame::RespHeader {
1714 required_schema_version: None,
1715 latest_schema_version: None,
1716 log_epoch: Some("epoch-1".to_owned()),
1717 reset_required: Some(false),
1718 },
1719 Frame::Error {
1720 code: self.code.to_owned(),
1721 message: "server error".to_owned(),
1722 category: "internal".to_owned(),
1723 retryable: self.retryable,
1724 recommended_action: "retryLater".to_owned(),
1725 details: None,
1726 },
1727 ],
1728 }))
1729 }
1730
1731 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1732 self.sync(request)
1733 }
1734
1735 fn download_segment(
1736 &mut self,
1737 _request: &SegmentRequest,
1738 _on_progress: &mut dyn FnMut(u64),
1739 ) -> Result<Vec<u8>, TransportError> {
1740 Err(TransportError::new("sync.transport_failed", "offline"))
1741 }
1742
1743 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1744 Ok(())
1745 }
1746
1747 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1748 Ok(())
1749 }
1750
1751 fn realtime_close(&mut self) -> Result<(), TransportError> {
1752 Ok(())
1753 }
1754 }
1755
1756 #[test]
1757 fn a_retryable_in_band_failure_keeps_the_socket_connected_and_still_asks_for_a_retry() {
1758 let mut client = client();
1759 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1760 let mut transport = ServerErrorTransport {
1761 code: "sync.schema_not_ready",
1762 retryable: true,
1763 };
1764 client.connect_realtime(&mut transport).expect("connect");
1765 assert!(matches!(
1766 client.sync(&mut transport),
1767 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.schema_not_ready"
1768 ));
1769 assert_eq!(
1770 client.realtime_state(),
1771 RealtimeState::Connected,
1772 "the socket delivered the answer, so it is not lost"
1773 );
1774 assert!(
1775 matches!(
1776 client.drain_sync_intents().as_slice(),
1777 [SyncIntent::Background { delay_ms: 250 }]
1778 ),
1779 "the retry deadline is a sync round and no reconnect satisfies it"
1780 );
1781 assert_eq!(
1782 client.progress().snapshot().unwrap().retry_delay_ms,
1783 Some(250)
1784 );
1785 }
1786
1787 #[test]
1788 fn retryable_server_error_schedules_a_background_retry() {
1789 let mut client = client();
1790 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1791 let mut transport = ServerErrorTransport {
1792 code: "sync.internal_error",
1793 retryable: true,
1794 };
1795 assert!(matches!(
1796 client.sync(&mut transport),
1797 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1798 ));
1799 assert!(matches!(
1800 client.drain_sync_intents().as_slice(),
1801 [SyncIntent::Background { delay_ms: 250 }]
1802 ));
1803 assert_eq!(
1804 client.progress().snapshot().unwrap().retry_delay_ms,
1805 Some(250)
1806 );
1807 let mut transport = ServerErrorTransport {
1808 code: "sync.invalid_request",
1809 retryable: false,
1810 };
1811 assert!(matches!(
1812 client.sync(&mut transport),
1813 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1814 ));
1815 assert!(client.drain_sync_intents().is_empty());
1816 let progress = client.progress().snapshot().unwrap();
1817 assert_eq!(progress.state, ProgressState::Failed);
1818 assert_eq!(progress.retry_delay_ms, None);
1819 }
1820
1821 #[test]
1822 fn sync_until_idle_reports_budget_exhaustion_or_failure() {
1823 let exhausted = SyncOutcome::BudgetExhausted(SyncReport::default()).to_json();
1826 assert_eq!(exhausted["ok"], json!(true));
1827 assert_eq!(exhausted["budgetExhausted"], json!(true));
1828 assert!(exhausted["report"].is_object());
1829
1830 let mut client = client();
1831 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1832 let mut idle_transport = HostTransport::new_from_config(&json!({})).unwrap();
1834 let zero = client.sync_until_idle(&mut idle_transport, Some(0));
1835 assert!(
1836 matches!(zero, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
1837 "{zero:?}"
1838 );
1839 let mut erroring = ServerErrorTransport {
1841 code: "sync.internal_error",
1842 retryable: false,
1843 };
1844 let failed = client.sync_until_idle(&mut erroring, Some(3));
1845 assert!(
1846 matches!(failed, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1847 "{failed:?}"
1848 );
1849 }
1850
1851 struct ScriptedSyncTransport {
1854 responses: std::collections::VecDeque<Message>,
1855 calls: usize,
1856 }
1857
1858 impl Transport for ScriptedSyncTransport {
1859 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1860 self.calls += 1;
1861 let response = self.responses.pop_front().expect("scripted response");
1862 Ok(encode_message(&response))
1863 }
1864
1865 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1866 self.sync(request)
1867 }
1868
1869 fn download_segment(
1870 &mut self,
1871 _request: &SegmentRequest,
1872 _on_progress: &mut dyn FnMut(u64),
1873 ) -> Result<Vec<u8>, TransportError> {
1874 Err(TransportError::new("sync.transport_failed", "offline"))
1875 }
1876
1877 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1878 Ok(())
1879 }
1880
1881 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1882 Ok(())
1883 }
1884
1885 fn realtime_close(&mut self) -> Result<(), TransportError> {
1886 Ok(())
1887 }
1888 }
1889
1890 #[test]
1891 fn sync_until_idle_success_then_failure_keeps_earlier_work() {
1892 let path = std::env::temp_dir().join(format!(
1893 "syncular-budget-script-{}.sqlite",
1894 uuid::Uuid::new_v4()
1895 ));
1896 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1897 {"name":"id","type":"string","nullable":false},
1898 {"name":"project_id","type":"string","nullable":false}],
1899 "scopes":[{"pattern":"project:{project_id}"}]}]});
1900 let mut client = SyncClient::open_path(
1901 "budget-script".into(),
1902 &schema,
1903 ClientLimits::default(),
1904 path.to_str().unwrap(),
1905 )
1906 .expect("open client");
1907 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1908 client
1909 .subscribe(
1910 "tasks".into(),
1911 "tasks".into(),
1912 vec![("project_id".into(), vec!["p1".into()])],
1913 None,
1914 )
1915 .unwrap();
1916 client.subs[0].cursor = 0;
1917 client.subs[0].synced_once = true;
1918 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1919 client.persist_sub(&client.subs[0]).unwrap();
1920 client.drain_change_batches();
1921
1922 let row = encode_row_json(
1923 client.schema.table("tasks").unwrap(),
1924 "kept",
1925 &Map::from_iter([
1926 ("id".into(), json!("kept")),
1927 ("project_id".into(), json!("p1")),
1928 ]),
1929 &client.encryption,
1930 )
1931 .unwrap();
1932 let success = Message {
1933 wire_version: WIRE_VERSION,
1934 msg_kind: MsgKind::Response,
1935 frames: vec![
1936 Frame::RespHeader {
1937 required_schema_version: None,
1938 latest_schema_version: None,
1939 log_epoch: Some("epoch-1".into()),
1940 reset_required: Some(false),
1941 },
1942 Frame::SubStart {
1943 id: "tasks".into(),
1944 status: SubStatus::Active,
1945 reason_code: String::new(),
1946 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1947 bootstrap: false,
1948 },
1949 Frame::Commit {
1950 commit_seq: 1,
1951 created_at_ms: 1,
1952 actor_id: "writer".into(),
1953 tables: vec!["tasks".into()],
1954 changes: vec![ssp2::model::Change {
1955 table_index: 0,
1956 row_id: "kept".into(),
1957 op: Op::Upsert,
1958 row_version: Some(1),
1959 scopes: vec![("project_id".into(), "p1".into())],
1960 row: Some(row),
1961 }],
1962 },
1963 Frame::SubEnd {
1964 next_cursor: 1,
1965 bootstrap_state: None,
1966 },
1967 ],
1968 };
1969 let failure = Message {
1970 wire_version: WIRE_VERSION,
1971 msg_kind: MsgKind::Response,
1972 frames: vec![
1973 Frame::RespHeader {
1974 required_schema_version: None,
1975 latest_schema_version: None,
1976 log_epoch: Some("epoch-1".into()),
1977 reset_required: Some(false),
1978 },
1979 Frame::Error {
1980 code: "sync.internal_error".into(),
1981 message: "second round failed".into(),
1982 category: "internal".into(),
1983 retryable: false,
1984 recommended_action: "retryLater".into(),
1985 details: None,
1986 },
1987 ],
1988 };
1989 let mut transport = ScriptedSyncTransport {
1990 responses: std::collections::VecDeque::from([success, failure]),
1991 calls: 0,
1992 };
1993 let outcome = client.sync_until_idle(&mut transport, Some(2));
1994 assert!(
1995 matches!(outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1996 "{outcome:?}"
1997 );
1998 assert_eq!(
1999 transport.calls, 2,
2000 "one successful round then the failing round"
2001 );
2002 assert_eq!(
2003 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2004 json!("kept"),
2005 "the earlier round's durable work survives the later failure"
2006 );
2007 drop(client);
2008 let _ = std::fs::remove_file(&path);
2009 }
2010
2011 #[test]
2012 fn sync_until_idle_stops_on_a_schema_floor() {
2013 let mut client = client();
2014 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2015 let floor = Message {
2016 wire_version: WIRE_VERSION,
2017 msg_kind: MsgKind::Response,
2018 frames: vec![Frame::RespHeader {
2019 required_schema_version: Some(2),
2020 latest_schema_version: Some(2),
2021 log_epoch: Some("epoch-1".into()),
2022 reset_required: Some(false),
2023 }],
2024 };
2025 let mut transport = ScriptedSyncTransport {
2026 responses: std::collections::VecDeque::from([floor.clone(), floor]),
2027 calls: 0,
2028 };
2029 let outcome = client.sync_until_idle(&mut transport, Some(3));
2030 match outcome {
2031 SyncOutcome::Ok(report) => assert_eq!(
2032 report
2033 .schema_floor
2034 .as_ref()
2035 .and_then(|floor| floor.required_schema_version),
2036 Some(2),
2037 "the stop retains the latest schema floor"
2038 ),
2039 other => panic!("expected a schema-floor stop, got {other:?}"),
2040 }
2041 assert_eq!(
2042 transport.calls, 1,
2043 "a schema floor stops the budget loop after one round"
2044 );
2045 }
2046
2047 struct FailureScriptTransport {
2050 rounds: std::collections::VecDeque<Result<Vec<u8>, TransportError>>,
2051 requests: Vec<Message>,
2052 }
2053
2054 impl Transport for FailureScriptTransport {
2055 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2056 self.requests
2057 .push(decode_message(request).expect("the client encodes a legal request"));
2058 self.rounds.pop_front().expect("scripted round")
2059 }
2060
2061 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2062 self.sync(request)
2063 }
2064
2065 fn download_segment(
2066 &mut self,
2067 _request: &SegmentRequest,
2068 _on_progress: &mut dyn FnMut(u64),
2069 ) -> Result<Vec<u8>, TransportError> {
2070 Err(TransportError::new("sync.transport_failed", "offline"))
2071 }
2072
2073 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2074 Ok(())
2075 }
2076
2077 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2078 Ok(())
2079 }
2080
2081 fn realtime_close(&mut self) -> Result<(), TransportError> {
2082 Ok(())
2083 }
2084 }
2085
2086 fn completed_response(frames: Vec<Frame>) -> Vec<u8> {
2088 let mut all = vec![Frame::RespHeader {
2089 required_schema_version: None,
2090 latest_schema_version: None,
2091 log_epoch: Some("epoch-1".into()),
2092 reset_required: Some(false),
2093 }];
2094 all.extend(frames);
2095 encode_message(&Message {
2096 wire_version: WIRE_VERSION,
2097 msg_kind: MsgKind::Response,
2098 frames: all,
2099 })
2100 }
2101
2102 fn carried_round_failures(request: &Message) -> Vec<String> {
2103 request
2104 .frames
2105 .iter()
2106 .filter_map(|frame| match frame {
2107 Frame::RoundFailure { code } => Some(code.clone()),
2108 _ => None,
2109 })
2110 .collect()
2111 }
2112
2113 fn failure_transport(
2114 rounds: impl IntoIterator<Item = Result<Vec<u8>, TransportError>>,
2115 ) -> FailureScriptTransport {
2116 FailureScriptTransport {
2117 rounds: rounds.into_iter().collect(),
2118 requests: Vec::new(),
2119 }
2120 }
2121
2122 #[test]
2123 fn a_round_without_a_complete_response_reports_its_code_on_every_following_request() {
2124 let mut client = client();
2125 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2126 let mut transport = failure_transport([
2127 Err(TransportError::new("transport.timeout", "round timed out")),
2128 Ok(completed_response(Vec::new())),
2129 Ok(completed_response(Vec::new())),
2130 ]);
2131 for _ in 0..3 {
2132 client.sync(&mut transport);
2133 }
2134 assert!(carried_round_failures(&transport.requests[0]).is_empty());
2135 assert_eq!(
2136 carried_round_failures(&transport.requests[1]),
2137 vec!["transport.timeout"]
2138 );
2139 assert!(
2140 matches!(transport.requests[1].frames[0], Frame::ReqHeader { .. })
2141 && matches!(transport.requests[1].frames[1], Frame::RoundFailure { .. }),
2142 "the report sits immediately after REQ_HEADER"
2143 );
2144 assert!(
2145 carried_round_failures(&transport.requests[2]).is_empty(),
2146 "a complete response ends the report"
2147 );
2148 }
2149
2150 #[test]
2151 fn a_newer_round_failure_replaces_the_kept_code() {
2152 let mut client = client();
2153 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2154 let mut transport = failure_transport([
2155 Err(TransportError::new("transport.timeout", "round timed out")),
2156 Err(TransportError::new("transport.failed", "socket closed")),
2157 Ok(completed_response(Vec::new())),
2158 Ok(completed_response(Vec::new())),
2159 ]);
2160 for _ in 0..4 {
2161 client.sync(&mut transport);
2162 }
2163 assert_eq!(
2164 carried_round_failures(&transport.requests[1]),
2165 vec!["transport.timeout"]
2166 );
2167 assert_eq!(
2168 carried_round_failures(&transport.requests[2]),
2169 vec!["transport.failed"]
2170 );
2171 assert!(carried_round_failures(&transport.requests[3]).is_empty());
2172 }
2173
2174 #[test]
2175 fn an_in_band_error_completes_the_round_and_is_never_reported() {
2176 let mut client = client();
2177 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2178 let mut transport = failure_transport([
2179 Err(TransportError::new("transport.timeout", "round timed out")),
2180 Ok(completed_response(vec![Frame::Error {
2181 code: "sync.internal_error".into(),
2182 message: "server error".into(),
2183 category: "internal".into(),
2184 retryable: false,
2185 recommended_action: "retryLater".into(),
2186 details: None,
2187 }])),
2188 Ok(completed_response(Vec::new())),
2189 ]);
2190 client.sync(&mut transport);
2191 assert!(matches!(
2192 client.sync(&mut transport),
2193 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
2194 ));
2195 client.sync(&mut transport);
2196 assert_eq!(
2197 carried_round_failures(&transport.requests[1]),
2198 vec!["transport.timeout"]
2199 );
2200 assert!(
2201 carried_round_failures(&transport.requests[2]).is_empty(),
2202 "the server already recorded an in-band ERROR"
2203 );
2204 }
2205
2206 #[test]
2207 fn a_code_the_frame_grammar_rejects_is_not_attached() {
2208 let mut client = client();
2209 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2210 let mut transport = failure_transport([
2211 Err(TransportError::new("offline", "no dotted code")),
2212 Ok(completed_response(Vec::new())),
2213 ]);
2214 client.sync(&mut transport);
2215 client.sync(&mut transport);
2216 assert!(carried_round_failures(&transport.requests[1]).is_empty());
2217 }
2218
2219 #[test]
2220 fn sync_report_merge_schema_floor_is_latest_state() {
2221 let mut aggregate = SyncReport {
2222 schema_floor: Some(SchemaFloor {
2223 required_schema_version: Some(2),
2224 latest_schema_version: Some(2),
2225 }),
2226 ..SyncReport::default()
2227 };
2228 aggregate.merge(&SyncReport::default());
2229 assert!(
2230 aggregate.schema_floor.is_none(),
2231 "a later round without a floor clears it"
2232 );
2233 }
2234
2235 #[test]
2236 fn push_capacity_limits_are_hard_and_keep_intent() {
2237 let mut c = client();
2239 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2240 c.limits.max_push_operations_per_request = Some(1);
2241 let id = c
2242 .mutate(vec![
2243 Mutation::Upsert {
2244 table: "tasks".into(),
2245 values: Map::from_iter([
2246 ("id".into(), json!("a")),
2247 ("project_id".into(), json!("p1")),
2248 ]),
2249 base_version: None,
2250 },
2251 Mutation::Upsert {
2252 table: "tasks".into(),
2253 values: Map::from_iter([
2254 ("id".into(), json!("b")),
2255 ("project_id".into(), json!("p1")),
2256 ]),
2257 base_version: None,
2258 },
2259 ])
2260 .unwrap();
2261 let error = c.build_request(false).unwrap_err();
2262 let SyncOutcome::Failed {
2263 error_code,
2264 details,
2265 ..
2266 } = *error
2267 else {
2268 panic!("expected a capacity failure");
2269 };
2270 assert_eq!(error_code, "client.push_request_too_large");
2271 let details = details.expect("capacity details");
2272 assert_eq!(details["kind"], json!("operations"));
2273 assert_eq!(details["limit"], json!(1));
2274 assert_eq!(details["size"], json!(2));
2275 assert_eq!(details["clientCommitId"], json!(id));
2276 assert_eq!(c.pending_commit_ids(), vec![id]);
2277
2278 let mut c = client();
2280 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2281 c.limits.max_push_commits_per_request = Some(1);
2282 let first = c
2283 .mutate(vec![Mutation::Upsert {
2284 table: "tasks".into(),
2285 values: Map::from_iter([
2286 ("id".into(), json!("one")),
2287 ("project_id".into(), json!("p1")),
2288 ]),
2289 base_version: None,
2290 }])
2291 .unwrap();
2292 let second = c
2293 .mutate(vec![Mutation::Upsert {
2294 table: "tasks".into(),
2295 values: Map::from_iter([
2296 ("id".into(), json!("two")),
2297 ("project_id".into(), json!("p1")),
2298 ]),
2299 base_version: None,
2300 }])
2301 .unwrap();
2302 let (_, meta) = c.build_request(false).unwrap();
2303 assert_eq!(meta.pushed_ids, vec![first.clone()]);
2304 assert_eq!(meta.deferred_commits, 1);
2305 assert_eq!(c.pending_commit_ids(), vec![first, second]);
2306 }
2307
2308 #[test]
2309 fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
2310 let mut c = client();
2313 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2314 let first = c
2315 .mutate(vec![Mutation::Upsert {
2316 table: "tasks".into(),
2317 values: Map::from_iter([
2318 ("id".into(), json!("one")),
2319 ("project_id".into(), json!("p1")),
2320 ]),
2321 base_version: None,
2322 }])
2323 .unwrap();
2324 let second = c
2325 .mutate(vec![Mutation::Upsert {
2326 table: "tasks".into(),
2327 values: Map::from_iter([
2328 ("id".into(), json!("two")),
2329 ("project_id".into(), json!("p1")),
2330 ]),
2331 base_version: None,
2332 }])
2333 .unwrap();
2334 let (message, meta) = c.build_request(false).unwrap();
2335 assert_eq!(meta.pushed_ids.len(), 2);
2336 let full = encode_message(&message).len();
2337 c.limits.max_push_request_bytes = Some(full - 1);
2338 let (_, meta) = c.build_request(false).unwrap();
2339 assert_eq!(meta.pushed_ids, vec![first.clone()]);
2340 assert_eq!(meta.deferred_commits, 1);
2341 assert_eq!(c.pending_commit_ids(), vec![first, second]);
2342
2343 let mut c = client();
2346 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2347 let huge = c
2348 .mutate(vec![Mutation::Upsert {
2349 table: "tasks".into(),
2350 values: Map::from_iter([
2351 ("id".into(), json!("x".repeat(20_000))),
2352 ("project_id".into(), json!("p1")),
2353 ]),
2354 base_version: None,
2355 }])
2356 .unwrap();
2357 let (message, meta) = c.build_request(false).unwrap();
2358 assert_eq!(meta.pushed_ids, vec![huge.clone()]);
2359 let with_huge = encode_message(&message).len();
2360 c.limits.max_push_request_bytes = Some(with_huge - 1);
2361 let error = c.build_request(false).unwrap_err();
2362 let SyncOutcome::Failed {
2363 error_code,
2364 details,
2365 ..
2366 } = *error
2367 else {
2368 panic!("expected a capacity failure");
2369 };
2370 assert_eq!(error_code, "client.push_request_too_large");
2371 let details = details.expect("capacity details");
2372 assert_eq!(details["kind"], json!("bytes"));
2373 assert_eq!(details["limit"], json!((with_huge - 1) as u64));
2374 assert_eq!(details["size"], json!(with_huge as u64));
2375 assert_eq!(details["clientCommitId"], json!(huge));
2376 assert_eq!(c.pending_commit_ids(), vec![huge]);
2377
2378 let mut c = client();
2380 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2381 c.limits.max_push_request_bytes = Some(1);
2382 let error = c.build_request(false).unwrap_err();
2383 let SyncOutcome::Failed { details, .. } = *error else {
2384 panic!("expected a capacity failure");
2385 };
2386 let details = details.expect("capacity details");
2387 assert_eq!(details["kind"], json!("bytes"));
2388 assert!(details.get("clientCommitId").is_none(), "{details}");
2389 }
2390
2391 #[test]
2392 fn push_limits_validate_at_construction() {
2393 for limits in [
2394 ClientLimits {
2395 max_push_commits_per_request: Some(0),
2396 ..Default::default()
2397 },
2398 ClientLimits {
2399 max_push_operations_per_request: Some(0),
2400 ..Default::default()
2401 },
2402 ClientLimits {
2403 max_push_request_bytes: Some(0),
2404 ..Default::default()
2405 },
2406 ClientLimits {
2407 max_push_request_bytes: Some(u32::MAX as usize + 1),
2408 ..Default::default()
2409 },
2410 ] {
2411 let schema = json!({"version":1,"tables":[]});
2412 let error = SyncClient::with_connection(
2413 "bad-limits".into(),
2414 &schema,
2415 limits,
2416 Connection::open_in_memory().unwrap(),
2417 )
2418 .err();
2419 assert_eq!(
2420 error.as_deref(),
2421 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2422 );
2423 }
2424 }
2425
2426 #[test]
2427 fn open_path_rejects_invalid_push_limits_before_storage() {
2428 for identity in [false, true] {
2429 let path = std::env::temp_dir().join(format!(
2430 "syncular-bad-limits-{}.sqlite",
2431 uuid::Uuid::new_v4()
2432 ));
2433 let schema = json!({"version":1,"tables":[]});
2434 let limits = ClientLimits {
2435 max_push_request_bytes: Some(0),
2436 ..Default::default()
2437 };
2438 let error = if identity {
2439 SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
2440 } else {
2441 SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
2442 }
2443 .err();
2444 assert_eq!(
2445 error.as_deref(),
2446 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2447 );
2448 assert!(
2449 !path.exists(),
2450 "an invalid limit is rejected before the database file is created"
2451 );
2452 }
2453 }
2454
2455 #[test]
2456 fn failed_progress_retry_delay_doubles_to_the_cap() {
2457 let mut client = client();
2458 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2459 let mut transport = ServerErrorTransport {
2460 code: "sync.internal_error",
2461 retryable: true,
2462 };
2463 let delays: Vec<Option<u64>> = (0..9)
2464 .map(|_| {
2465 client.sync(&mut transport);
2466 client.progress().snapshot().unwrap().retry_delay_ms
2467 })
2468 .collect();
2469 assert_eq!(
2470 delays,
2471 [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
2472 );
2473 }
2474
2475 #[test]
2476 fn background_retry_deadlines_back_off_and_reset() {
2477 let mut client = client();
2478 client.schedule_background_retry();
2479 assert!(matches!(
2480 client.drain_sync_intents().as_slice(),
2481 [SyncIntent::Background { delay_ms: 250 }]
2482 ));
2483 client.schedule_background_retry();
2484 assert!(matches!(
2485 client.drain_sync_intents().as_slice(),
2486 [SyncIntent::Background { delay_ms: 500 }]
2487 ));
2488 client.reset_background_retry();
2489 client.schedule_background_retry();
2490 assert!(matches!(
2491 client.drain_sync_intents().as_slice(),
2492 [SyncIntent::Background { delay_ms: 250 }]
2493 ));
2494 }
2495
2496 #[test]
2497 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
2498 let mut client = client();
2499 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2500 client
2501 .subscribe(
2502 "epoch-tasks".to_owned(),
2503 "tasks".to_owned(),
2504 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2505 None,
2506 )
2507 .expect("subscribe");
2508 client.drain_sync_intents();
2509
2510 client
2511 .run_log_epoch_reset("epoch-2")
2512 .expect("reset partition log epoch");
2513
2514 assert!(matches!(
2515 client.drain_sync_intents().as_slice(),
2516 [SyncIntent::Interactive]
2517 ));
2518 }
2519
2520 #[derive(Default)]
2521 struct CountingRealtimeTransport {
2522 connects: usize,
2523 closes: usize,
2524 messages: Vec<String>,
2525 blob_uploads: Vec<(String, Vec<u8>)>,
2526 blob_download: Option<Vec<u8>>,
2527 sync_calls: usize,
2528 realtime_sync_calls: usize,
2529 refuse_connect: bool,
2530 }
2531
2532 impl Transport for CountingRealtimeTransport {
2533 fn blob_upload(
2534 &mut self,
2535 blob_id: &str,
2536 bytes: &[u8],
2537 _media_type: Option<&str>,
2538 ) -> Result<(), TransportError> {
2539 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
2540 Ok(())
2541 }
2542
2543 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
2544 self.blob_download
2545 .clone()
2546 .map(BlobDownload::Bytes)
2547 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
2548 }
2549
2550 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2551 self.sync_calls += 1;
2552 Err(TransportError::new("sync.transport_failed", "offline"))
2553 }
2554
2555 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2556 self.realtime_sync_calls += 1;
2557 Err(TransportError::new("sync.transport_failed", "offline"))
2558 }
2559
2560 fn download_segment(
2561 &mut self,
2562 _request: &SegmentRequest,
2563 _on_progress: &mut dyn FnMut(u64),
2564 ) -> Result<Vec<u8>, TransportError> {
2565 Err(TransportError::new("sync.transport_failed", "offline"))
2566 }
2567
2568 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2569 self.connects += 1;
2570 if self.refuse_connect {
2571 return Err(TransportError::new("transport.failed", "refused"));
2572 }
2573 Ok(())
2574 }
2575
2576 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
2577 self.messages.push(text.to_owned());
2578 Ok(())
2579 }
2580
2581 fn realtime_close(&mut self) -> Result<(), TransportError> {
2582 self.closes += 1;
2583 Ok(())
2584 }
2585 }
2586
2587 #[test]
2588 fn blob_results_own_downloaded_bytes() {
2589 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
2590 {"name":"id","type":"string","nullable":false},
2591 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
2592 let bytes = b"owned typed result".to_vec();
2593 let blob_id = blob_id_for(&bytes);
2594 let mut transport = CountingRealtimeTransport {
2595 blob_download: Some(bytes.clone()),
2596 ..Default::default()
2597 };
2598 let result = {
2599 let mut client =
2600 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
2601 .unwrap();
2602 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
2603 assert_eq!(result.blob_id, blob_id);
2604 assert_eq!(result.byte_length, bytes.len() as i64);
2605 assert_eq!(result.bytes, bytes);
2606 assert_eq!(result.media_type, None);
2607 assert_eq!(
2608 client
2609 .conn
2610 .query_row(
2611 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
2612 rusqlite::params![blob_id],
2613 |row| row.get::<_, i64>(0),
2614 )
2615 .unwrap(),
2616 0
2617 );
2618 result
2619 };
2620 assert_eq!(result.bytes, bytes);
2621
2622 let mut client =
2623 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
2624 assert_eq!(
2625 client
2626 .fetch_blob_bytes(&mut transport, "not json")
2627 .unwrap_err(),
2628 (
2629 "client.failed".to_owned(),
2630 "blob ref is not JSON".to_owned()
2631 )
2632 );
2633 }
2634
2635 #[test]
2636 fn visible_blob_references_survive_cache_trimming() {
2637 let schema = json!({"version":1,"tables":[
2638 {"name":"attachments","primaryKey":"id","columns":[
2639 {"name":"id","type":"string","nullable":false},
2640 {"name":"body","type":"blob_ref","nullable":true},
2641 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
2642 {"name":"avatars","primaryKey":"id","columns":[
2643 {"name":"id","type":"string","nullable":false},
2644 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
2645 ]});
2646 let bytes = b"referenced above cap".to_vec();
2647 let blob_id = blob_id_for(&bytes);
2648 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
2649 let mut client = SyncClient::new(
2650 "visible-blob-refs".to_owned(),
2651 &schema,
2652 ClientLimits {
2653 blob_cache_max_bytes: Some(1),
2654 ..Default::default()
2655 },
2656 )
2657 .unwrap();
2658 client.conn.execute(
2659 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
2660 rusqlite::params![reference, reference, reference, reference],
2661 ).unwrap();
2662 client
2663 .conn
2664 .execute(
2665 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
2666 rusqlite::params![reference],
2667 )
2668 .unwrap();
2669 client.conn.execute(
2670 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
2671 [],
2672 ).unwrap();
2673 let mut transport = CountingRealtimeTransport {
2674 blob_download: Some(bytes.clone()),
2675 ..Default::default()
2676 };
2677
2678 assert_eq!(
2679 client
2680 .fetch_blob_bytes(&mut transport, &blob_id)
2681 .unwrap()
2682 .bytes,
2683 bytes
2684 );
2685 let rows = client
2686 .conn
2687 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
2688 .unwrap()
2689 .query_map([], |row| row.get::<_, String>(0))
2690 .unwrap()
2691 .collect::<Result<Vec<_>, _>>()
2692 .unwrap();
2693 assert_eq!(rows, vec![blob_id]);
2694 }
2695
2696 #[test]
2697 fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
2698 let mut transport = CountingRealtimeTransport::default();
2699 let path = std::env::temp_dir().join(format!(
2700 "syncular-transport-gate-{}.db",
2701 uuid::Uuid::new_v4()
2702 ));
2703 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2704 {"name":"id","type":"string","nullable":false},
2705 {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
2706 let mut client = SyncClient::open_path(
2707 "gate".into(),
2708 &schema,
2709 ClientLimits::default(),
2710 path.to_str().unwrap(),
2711 )
2712 .unwrap();
2713 client.connect_realtime(&mut transport).unwrap();
2714 client.set_transport_enabled(&mut transport, false);
2715 assert_eq!(transport.closes, 1);
2716 client.begin_security_preflight();
2717 client.activate_security(Default::default()).unwrap();
2718 client
2719 .subscribe(
2720 "tasks".into(),
2721 "tasks".into(),
2722 vec![("project_id".into(), vec!["p1".into()])],
2723 None,
2724 )
2725 .unwrap();
2726 for id in ["one", "two"] {
2727 client
2728 .mutate(vec![Mutation::Upsert {
2729 table: "tasks".into(),
2730 values: Map::from_iter([
2731 ("id".into(), json!(id)),
2732 ("project_id".into(), json!("p1")),
2733 ]),
2734 base_version: None,
2735 }])
2736 .unwrap();
2737 }
2738 assert_eq!(
2739 client
2740 .query("SELECT id FROM tasks ORDER BY id", &[])
2741 .unwrap()
2742 .len(),
2743 2
2744 );
2745 assert!(
2746 matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
2747 );
2748 assert!(client
2749 .connect_realtime(&mut transport)
2750 .unwrap_err()
2751 .starts_with("sync.offline:"));
2752 assert!(client
2753 .set_presence(&mut transport, "project:p1", None)
2754 .unwrap_err()
2755 .starts_with("sync.offline:"));
2756 assert_eq!(transport.sync_calls, 0);
2757 assert_eq!(transport.realtime_sync_calls, 0);
2758 assert_eq!(transport.connects, 1);
2759 assert!(transport.messages.is_empty());
2760 assert!(matches!(
2761 client.drain_sync_intents().as_slice(),
2762 [SyncIntent::None]
2763 ));
2764 let pending = client.pending_commit_ids();
2765 drop(client);
2766 let mut reopened = SyncClient::open_path(
2767 "gate".into(),
2768 &schema,
2769 ClientLimits::default(),
2770 path.to_str().unwrap(),
2771 )
2772 .unwrap();
2773 assert!(reopened.transport_enabled());
2774 reopened.set_transport_enabled(&mut transport, false);
2775 assert_eq!(reopened.pending_commit_ids(), pending);
2776 assert_eq!(
2777 reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
2778 2
2779 );
2780 reopened.set_transport_enabled(&mut transport, true);
2781 assert!(matches!(
2782 reopened.drain_sync_intents().as_slice(),
2783 [SyncIntent::Interactive]
2784 ));
2785 reopened.set_transport_enabled(&mut transport, true);
2786 assert!(reopened.drain_sync_intents().is_empty());
2787 drop(reopened);
2788 std::fs::remove_file(path).unwrap();
2789 }
2790
2791 #[test]
2792 fn realtime_connection_ownership_is_idempotent() {
2793 let mut client = client();
2794 let mut transport = CountingRealtimeTransport {
2795 connects: 0,
2796 closes: 0,
2797 ..Default::default()
2798 };
2799 client
2800 .connect_realtime(&mut transport)
2801 .expect("first connect");
2802 client
2803 .connect_realtime(&mut transport)
2804 .expect("idempotent connect");
2805 assert_eq!(transport.connects, 1);
2806 client.disconnect_realtime(&mut transport);
2807 client.disconnect_realtime(&mut transport);
2808 assert_eq!(transport.closes, 1);
2809 client
2810 .connect_realtime(&mut transport)
2811 .expect("deliberate reconnect");
2812 assert_eq!(transport.connects, 2);
2813 }
2814
2815 #[test]
2816 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
2817 let mut client = client();
2818 client
2819 .set_realtime_policy(RealtimePolicy::Required)
2820 .expect("set policy");
2821 let mut transport = CountingRealtimeTransport::default();
2822 match client.sync(&mut transport) {
2823 SyncOutcome::RealtimeUnavailable {
2824 state,
2825 reason_code,
2826 retry_delay_ms,
2827 } => {
2828 assert_eq!(state, RealtimeState::Disconnected);
2829 assert_eq!(reason_code, None);
2830 assert_eq!(retry_delay_ms, 250);
2831 }
2832 other => panic!("expected RealtimeUnavailable, got {other:?}"),
2833 }
2834 assert_eq!(transport.sync_calls, 0);
2835 assert!(matches!(
2836 client.drain_sync_intents().as_slice(),
2837 [SyncIntent::Background { delay_ms: 250 }]
2838 ));
2839 let snapshot = client
2840 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2841 .expect("diagnostics");
2842 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
2843 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
2844 assert_eq!(snapshot.host.realtime_reason_code, None);
2845 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
2846 assert_eq!(
2847 snapshot
2848 .last_round
2849 .as_ref()
2850 .and_then(|round| round.error_code.clone()),
2851 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
2852 );
2853 }
2854
2855 #[test]
2856 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
2857 let mut client = client();
2858 client
2859 .set_realtime_policy(RealtimePolicy::Required)
2860 .expect("set policy");
2861 let mut transport = CountingRealtimeTransport {
2862 refuse_connect: true,
2863 ..Default::default()
2864 };
2865 assert!(client.connect_realtime(&mut transport).is_err());
2866 assert_eq!(client.realtime_state(), RealtimeState::Refused);
2867 let snapshot = client
2868 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2869 .expect("diagnostics");
2870 assert_eq!(
2871 snapshot.host.realtime_reason_code.as_deref(),
2872 Some("transport.failed")
2873 );
2874 match client.sync(&mut transport) {
2875 SyncOutcome::RealtimeUnavailable {
2876 state, reason_code, ..
2877 } => {
2878 assert_eq!(state, RealtimeState::Refused);
2879 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
2880 }
2881 other => panic!("expected RealtimeUnavailable, got {other:?}"),
2882 }
2883 assert_eq!(transport.sync_calls, 0);
2884 assert_eq!(transport.realtime_sync_calls, 0);
2885 }
2886
2887 #[test]
2888 fn an_unsolicited_socket_end_is_lost_with_the_transport_code_and_a_retry_intent() {
2889 let mut client = client();
2890 let mut transport = CountingRealtimeTransport::default();
2891 let ended = TransportError::new("transport.failed", "realtime connection closed");
2892 client.realtime_lost(&mut transport, &ended);
2893 assert_eq!(
2894 client.realtime_state(),
2895 RealtimeState::Disconnected,
2896 "a socket that was never connected has nothing to lose"
2897 );
2898 assert!(client.drain_sync_intents().is_empty());
2899
2900 client.connect_realtime(&mut transport).expect("connect");
2901 client.realtime_lost(&mut transport, &ended);
2902 assert_eq!(client.realtime_state(), RealtimeState::Lost);
2903 assert_eq!(transport.closes, 1);
2904 assert!(matches!(
2905 client.drain_sync_intents().as_slice(),
2906 [SyncIntent::Background { delay_ms: 250 }]
2907 ));
2908 let snapshot = client
2909 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2910 .expect("diagnostics");
2911 assert_eq!(snapshot.host.realtime, RealtimeState::Lost);
2912 assert_eq!(
2913 snapshot.host.realtime_reason_code.as_deref(),
2914 Some("transport.failed")
2915 );
2916 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
2917
2918 client.realtime_lost(&mut transport, &ended);
2919 assert!(
2920 client.drain_sync_intents().is_empty(),
2921 "a second report for the same loss schedules nothing"
2922 );
2923 client.connect_realtime(&mut transport).expect("reconnect");
2924 assert_eq!(client.realtime_state(), RealtimeState::Connected);
2925 }
2926
2927 #[test]
2928 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
2929 let mut client = client();
2930 client
2931 .set_realtime_policy(RealtimePolicy::Required)
2932 .expect("set policy");
2933 let mut transport = CountingRealtimeTransport::default();
2934 client.connect_realtime(&mut transport).expect("connect");
2935 assert_eq!(client.realtime_state(), RealtimeState::Connected);
2936 assert!(
2937 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
2938 "a policy change cannot silently re-route a connected socket"
2939 );
2940 assert!(matches!(
2941 client.sync(&mut transport),
2942 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
2943 ));
2944 assert_eq!(client.realtime_state(), RealtimeState::Lost);
2945 assert_eq!(transport.realtime_sync_calls, 1);
2946 assert_eq!(transport.sync_calls, 0);
2947 let snapshot = client
2948 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2949 .expect("diagnostics");
2950 assert_eq!(
2951 snapshot.host.realtime_reason_code.as_deref(),
2952 Some("sync.transport_failed")
2953 );
2954 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
2955 match client.sync(&mut transport) {
2956 SyncOutcome::RealtimeUnavailable {
2957 state, reason_code, ..
2958 } => {
2959 assert_eq!(state, RealtimeState::Lost);
2960 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
2961 }
2962 other => panic!("expected RealtimeUnavailable, got {other:?}"),
2963 }
2964 assert_eq!(transport.sync_calls, 0);
2965 client.disconnect_realtime(&mut transport);
2966 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
2967 assert_eq!(transport.closes, 1);
2968 client.connect_realtime(&mut transport).expect("reconnect");
2969 assert_eq!(client.realtime_state(), RealtimeState::Connected);
2970 assert_eq!(transport.connects, 2);
2971 }
2972
2973 #[test]
2974 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
2975 let mut client = client();
2976 client
2977 .set_realtime_policy(RealtimePolicy::Off)
2978 .expect("set policy");
2979 let mut transport = CountingRealtimeTransport::default();
2980 assert!(client.connect_realtime(&mut transport).is_err());
2981 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
2982 assert!(matches!(
2983 client.sync(&mut transport),
2984 SyncOutcome::Failed { .. }
2985 ));
2986 assert_eq!(transport.sync_calls, 1);
2987 assert_eq!(transport.realtime_sync_calls, 0);
2988 let snapshot = client
2989 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2990 .expect("diagnostics");
2991 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
2992 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
2993 }
2994
2995 #[test]
2996 fn optional_policy_keeps_the_http_round() {
2997 let mut client = client();
2998 let mut transport = CountingRealtimeTransport::default();
2999 assert!(matches!(
3000 client.sync(&mut transport),
3001 SyncOutcome::Failed { .. }
3002 ));
3003 assert_eq!(transport.sync_calls, 1);
3004 assert_eq!(transport.realtime_sync_calls, 0);
3005 let snapshot = client
3006 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3007 .expect("diagnostics");
3008 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3009 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
3010 }
3011
3012 #[test]
3013 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
3014 let mut client = client();
3015 client
3016 .subscribe(
3017 "repair-tasks".to_owned(),
3018 "tasks".to_owned(),
3019 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3020 None,
3021 )
3022 .expect("subscribe");
3023 {
3024 let sub = client
3025 .subs
3026 .iter_mut()
3027 .find(|sub| sub.id == "repair-tasks")
3028 .expect("subscription");
3029 sub.cursor = 42;
3030 sub.synced_once = true;
3031 let persisted = sub.clone();
3032 client.persist_sub(&persisted).unwrap();
3033 }
3034 for table in ["_syncular_base_tasks", "tasks"] {
3035 client
3036 .conn
3037 .execute(
3038 &format!(
3039 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
3040 ),
3041 rusqlite::params!["server-row", "p1"],
3042 )
3043 .expect("seed server row");
3044 }
3045 let pending = client
3046 .mutate(vec![Mutation::Upsert {
3047 table: "tasks".to_owned(),
3048 values: Map::from_iter([
3049 ("id".to_owned(), Value::from("offline-row")),
3050 ("project_id".to_owned(), Value::from("p1")),
3051 ]),
3052 base_version: None,
3053 }])
3054 .expect("queue offline work");
3055 client.drain_change_batches();
3056 client.drain_sync_intents();
3057
3058 assert_eq!(
3059 client
3060 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3061 rebootstrap_id: "support-case-001".to_owned(),
3062 })
3063 .expect("rebootstrap"),
3064 LocalDataRebootstrapResult {
3065 already_applied: false,
3066 retained_commits: 1,
3067 reset_subscriptions: 1,
3068 }
3069 );
3070 let visible_ids = client
3071 .conn
3072 .prepare("SELECT id FROM tasks ORDER BY id")
3073 .expect("prepare visible ids")
3074 .query_map([], |row| row.get::<_, String>(0))
3075 .expect("query visible ids")
3076 .collect::<Result<Vec<_>, _>>()
3077 .expect("collect visible ids");
3078 assert_eq!(visible_ids, vec!["offline-row"]);
3079 assert_eq!(client.pending_commit_ids(), vec![pending]);
3080 assert_eq!(
3081 client
3082 .subscription_state("repair-tasks")
3083 .expect("subscription")
3084 .cursor,
3085 -1
3086 );
3087 assert!(client.upgrading());
3088 assert!(client.sync_needed());
3089 assert!(matches!(
3090 client.drain_sync_intents().as_slice(),
3091 [SyncIntent::Interactive]
3092 ));
3093 assert_eq!(client.drain_change_batches().len(), 1);
3094
3095 assert_eq!(
3096 client
3097 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3098 rebootstrap_id: "support-case-001".to_owned(),
3099 })
3100 .expect("idempotent retry"),
3101 LocalDataRebootstrapResult {
3102 already_applied: true,
3103 retained_commits: 1,
3104 reset_subscriptions: 1,
3105 }
3106 );
3107 assert!(client.drain_change_batches().is_empty());
3108 }
3109
3110 #[test]
3111 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
3112 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
3113 assert_eq!(
3114 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
3115 (3, 4)
3116 );
3117 assert_eq!(
3118 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
3119 (0, 0)
3120 );
3121 for malformed in [
3122 "",
3123 "{}",
3124 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
3125 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
3126 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
3127 ] {
3128 assert_eq!(
3129 decode_local_rebootstrap_receipt(malformed)
3130 .expect_err("malformed receipt must fail"),
3131 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3132 );
3133 }
3134 }
3135
3136 #[test]
3137 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
3138 let path = std::env::temp_dir().join(format!(
3139 "syncular-rebootstrap-receipt-{}.db",
3140 uuid::Uuid::new_v4()
3141 ));
3142 let schema = json!({
3143 "version": 1,
3144 "tables": [{
3145 "name": "tasks",
3146 "primaryKey": "id",
3147 "columns": [
3148 { "name": "id", "type": "string", "nullable": false },
3149 { "name": "project_id", "type": "string", "nullable": false }
3150 ],
3151 "scopes": [{ "pattern": "project:{project_id}" }]
3152 }]
3153 });
3154 let path_string = path.to_str().expect("UTF-8 temp path");
3155
3156 {
3157 let mut first = SyncClient::open_path(
3158 "repair-restart-client".to_owned(),
3159 &schema,
3160 ClientLimits::default(),
3161 path_string,
3162 )
3163 .expect("first open");
3164 first
3165 .subscribe(
3166 "repair-tasks".to_owned(),
3167 "tasks".to_owned(),
3168 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3169 None,
3170 )
3171 .expect("subscribe");
3172 first
3173 .mutate(vec![Mutation::Upsert {
3174 table: "tasks".to_owned(),
3175 values: Map::from_iter([
3176 ("id".to_owned(), Value::from("offline-row")),
3177 ("project_id".to_owned(), Value::from("p1")),
3178 ]),
3179 base_version: None,
3180 }])
3181 .expect("queue offline work");
3182 assert_eq!(
3183 first
3184 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3185 rebootstrap_id: "restart-receipt".to_owned(),
3186 })
3187 .expect("first rebootstrap"),
3188 LocalDataRebootstrapResult {
3189 already_applied: false,
3190 retained_commits: 1,
3191 reset_subscriptions: 1,
3192 }
3193 );
3194 }
3195
3196 let mut reopened = SyncClient::open_path(
3197 "repair-restart-client".to_owned(),
3198 &schema,
3199 ClientLimits::default(),
3200 path_string,
3201 )
3202 .expect("reopen");
3203 reopened.drain_change_batches();
3204 reopened.drain_sync_intents();
3205 assert_eq!(
3206 reopened
3207 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3208 rebootstrap_id: "restart-receipt".to_owned(),
3209 })
3210 .expect("receipt replay"),
3211 LocalDataRebootstrapResult {
3212 already_applied: true,
3213 retained_commits: 1,
3214 reset_subscriptions: 1,
3215 }
3216 );
3217 assert!(reopened.drain_change_batches().is_empty());
3218 assert!(reopened.drain_sync_intents().is_empty());
3219 drop(reopened);
3220 std::fs::remove_file(path).expect("remove temp database");
3221 }
3222
3223 #[test]
3224 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
3225 let mut malformed = client();
3226 malformed
3227 .subscribe(
3228 "repair-tasks".to_owned(),
3229 "tasks".to_owned(),
3230 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3231 None,
3232 )
3233 .expect("subscribe");
3234 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
3235 malformed.drain_change_batches();
3236 malformed.drain_sync_intents();
3237 let malformed_error = malformed
3238 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3239 rebootstrap_id: "malformed".to_owned(),
3240 })
3241 .expect_err("malformed receipt must fail closed");
3242 assert_eq!(
3243 malformed_error,
3244 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3245 );
3246 assert!(!malformed.upgrading());
3247 assert_eq!(
3248 malformed
3249 .subscription_state("repair-tasks")
3250 .expect("unchanged subscription")
3251 .cursor,
3252 -1
3253 );
3254 assert!(malformed.drain_change_batches().is_empty());
3255 assert!(malformed.drain_sync_intents().is_empty());
3256
3257 let mut unreadable = client();
3258 unreadable
3259 .conn
3260 .execute(
3261 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
3262 rusqlite::params!["server-row", "p1"],
3263 )
3264 .expect("seed visible row");
3265 unreadable
3266 .conn
3267 .execute("DROP TABLE _syncular_meta", [])
3268 .expect("break marker storage");
3269 let unreadable_error = unreadable
3270 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3271 rebootstrap_id: "unreadable".to_owned(),
3272 })
3273 .expect_err("unreadable marker storage must fail closed");
3274 assert_eq!(
3275 unreadable_error,
3276 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
3277 );
3278 let visible_rows = unreadable
3279 .conn
3280 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
3281 .expect("visible projection remains");
3282 assert_eq!(visible_rows, 1);
3283 assert!(!unreadable.upgrading());
3284 assert!(unreadable.drain_change_batches().is_empty());
3285 assert!(unreadable.drain_sync_intents().is_empty());
3286 }
3287
3288 #[test]
3289 fn local_rebootstrap_cannot_bypass_schema_floor() {
3290 let mut client = client();
3291 client.set_schema_floor(Some(SchemaFloor {
3292 required_schema_version: Some(2),
3293 latest_schema_version: Some(2),
3294 }));
3295 let error = client
3296 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3297 rebootstrap_id: "blocked-floor".to_owned(),
3298 })
3299 .expect_err("schema floor must block repair");
3300 assert!(error.contains("cannot bypass an active schema-floor stop"));
3301 }
3302
3303 #[test]
3304 fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
3305 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3306 "columns":[{"name":"id","type":"string","nullable":false},
3307 {"name":"project_id","type":"string","nullable":false},
3308 {"name":"starts_at_ms","type":"integer","nullable":false},
3309 {"name":"version","type":"integer","nullable":false}],
3310 "scopes":[{"pattern":"project:{project_id}"}]}]});
3311 let mut client =
3312 SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
3313 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3314 client
3315 .write_base_row(
3316 "tasks",
3317 &vec![
3318 Some(ColumnValue::String("t1".into())),
3319 Some(ColumnValue::String("p1".into())),
3320 Some(ColumnValue::Integer(100)),
3321 Some(ColumnValue::Integer(1)),
3322 ],
3323 1,
3324 )
3325 .unwrap();
3326 client.rebuild_overlay().expect("rebuild overlay");
3327 let first = client
3328 .patch(
3329 "tasks",
3330 "t1",
3331 Map::from_iter([
3332 ("starts_at_ms".into(), json!(200)),
3333 ("version".into(), json!(2)),
3334 ]),
3335 None,
3336 )
3337 .unwrap();
3338 let (_, first_request) = client.build_request(false).unwrap();
3339 let second = client
3341 .patch(
3342 "tasks",
3343 "t1",
3344 Map::from_iter([
3345 ("starts_at_ms".into(), json!(300)),
3346 ("version".into(), json!(3)),
3347 ]),
3348 None,
3349 )
3350 .unwrap();
3351 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3352 for (id, seq, meta) in [
3353 (first, 2, first_request),
3354 (second, 3, client.build_request(false).unwrap().1),
3355 ] {
3356 let response = Message {
3357 wire_version: WIRE_VERSION,
3358 msg_kind: MsgKind::Response,
3359 frames: vec![
3360 Frame::RespHeader {
3361 required_schema_version: None,
3362 latest_schema_version: None,
3363 log_epoch: Some("epoch-1".into()),
3364 reset_required: Some(false),
3365 },
3366 Frame::PushResult {
3367 client_commit_id: id,
3368 status: PushStatus::Applied,
3369 commit_seq: Some(seq),
3370 results: vec![OpResult::Applied { op_index: 0 }],
3371 },
3372 ],
3373 };
3374 assert!(matches!(
3375 client.process_response(&mut transport, response, &meta),
3376 SyncOutcome::Ok(_)
3377 ));
3378 let row = &client
3379 .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
3380 .unwrap()[0];
3381 assert_eq!(
3382 row["starts_at_ms"],
3383 json!(300),
3384 "an applied ACK without a pull image must not expose the old base"
3385 );
3386 assert_eq!(
3387 row["version"],
3388 json!(3),
3389 "the queued domain version must remain monotonic"
3390 );
3391 }
3392 }
3393
3394 #[test]
3395 fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
3396 for stored in [None, Some("epoch-old")] {
3397 let mut client = client();
3398 client
3399 .subscribe(
3400 "tasks".into(),
3401 "tasks".into(),
3402 vec![("project_id".into(), vec!["p1".into()])],
3403 None,
3404 )
3405 .unwrap();
3406 client
3407 .conn
3408 .execute_batch(
3409 "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
3410 )
3411 .unwrap();
3412 let id = client
3413 .mutate(vec![Mutation::Upsert {
3414 table: "tasks".into(),
3415 values: Map::from_iter([
3416 ("id".into(), json!("offline")),
3417 ("project_id".into(), json!("p1")),
3418 ]),
3419 base_version: None,
3420 }])
3421 .unwrap();
3422 if let Some(epoch) = stored {
3423 client.set_meta(LOG_EPOCH_KEY, epoch);
3424 }
3425 let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
3426 let (_, meta) = client.build_request(false).unwrap();
3427 client.drain_change_batches();
3428 client.drain_sync_intents();
3429 let response = Message {
3430 wire_version: WIRE_VERSION,
3431 msg_kind: MsgKind::Response,
3432 frames: vec![Frame::RespHeader {
3433 required_schema_version: None,
3434 latest_schema_version: None,
3435 log_epoch: Some("epoch-new".into()),
3436 reset_required: Some(true),
3437 }],
3438 };
3439 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3440 let outcome = client.process_response(&mut transport, response, &meta);
3441 let SyncOutcome::Ok(report) = outcome else {
3442 panic!("epoch round failed: {outcome:?}");
3443 };
3444 assert_eq!(
3445 report.resets,
3446 if stored.is_none() {
3447 Vec::<String>::new()
3448 } else {
3449 vec!["tasks".into()]
3450 }
3451 );
3452 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3453 assert_eq!(client.pending_commit_ids(), vec![id]);
3454 assert_eq!(
3455 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3456 json!("offline")
3457 );
3458 let trigger: i64 = client
3459 .conn
3460 .query_row(
3461 "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
3462 [],
3463 |row| row.get(0),
3464 )
3465 .unwrap();
3466 if stored.is_none() {
3467 assert!(!client.upgrading);
3468 assert!(client
3469 .drain_change_batches()
3470 .iter()
3471 .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
3472 assert_eq!(
3473 serde_json::to_value(client.subscription_state("tasks")).unwrap(),
3474 before
3475 );
3476 assert_eq!(trigger, 1, "acquisition must not drop the synced table");
3477 } else {
3478 assert!(client.upgrading);
3479 assert_eq!(trigger, 0, "an epoch change must drop the old table");
3480 }
3481 assert!(matches!(
3482 client.drain_sync_intents().as_slice(),
3483 [SyncIntent::Interactive]
3484 ));
3485 }
3486 }
3487
3488 #[test]
3489 fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
3490 for key in ["logEpoch", "localRevision"] {
3491 let mut client = client();
3492 let id = client
3493 .mutate(vec![Mutation::Upsert {
3494 table: "tasks".into(),
3495 values: Map::from_iter([
3496 ("id".into(), json!("offline")),
3497 ("project_id".into(), json!("p1")),
3498 ]),
3499 base_version: None,
3500 }])
3501 .unwrap();
3502 let revision = client.local_revision();
3503 let needed = client.sync_needed;
3504 client.drain_change_batches();
3505 client.drain_sync_intents();
3506 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();
3507 assert!(client.run_log_epoch_reset("epoch-new").is_err());
3508 assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
3509 assert!(!client.upgrading);
3510 assert_eq!(client.sync_needed, needed);
3511 assert_eq!(client.local_revision(), revision);
3512 assert_eq!(client.pending_commit_ids(), [id]);
3513 assert!(client.drain_change_batches().is_empty());
3514 assert!(client.drain_sync_intents().is_empty());
3515 }
3516 }
3517
3518 #[test]
3519 fn failed_log_epoch_resets_restore_memory_and_durable_state() {
3520 for key in [
3521 "localSchemaVersion",
3522 "localSchemaDescriptor",
3523 "logEpoch",
3524 "localRevision",
3525 ] {
3526 let mut client = client();
3527 client.set_meta(LOG_EPOCH_KEY, "epoch-old");
3528 let commit = client
3529 .mutate(vec![Mutation::Upsert {
3530 table: "tasks".into(),
3531 values: Map::from_iter([
3532 ("id".into(), json!("offline")),
3533 ("project_id".into(), json!("p1")),
3534 ]),
3535 base_version: None,
3536 }])
3537 .unwrap();
3538 client.active_round = Some(uuid::Uuid::new_v4());
3539 client.overlay_dirty.set(false);
3540 let round = client.active_round;
3541 let needed = client.sync_needed;
3542 let revision = client.local_revision();
3543 let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
3544 client.drain_change_batches();
3545 client.drain_sync_intents();
3546 client.conn.execute_batch(&format!("CREATE TRIGGER fail_reset BEFORE INSERT ON _syncular_meta WHEN NEW.key='{key}' BEGIN SELECT RAISE(FAIL,'injected reset failure'); END")).unwrap();
3547 assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
3548 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
3549 assert!(!client.upgrading);
3550 assert_eq!(client.active_round, round);
3551 assert!(!client.overlay_dirty.get());
3552 assert_eq!(client.sync_needed, needed);
3553 assert_eq!(client.local_revision(), revision);
3554 assert_eq!(
3555 client.pending_commit_ids().as_slice(),
3556 std::slice::from_ref(&commit)
3557 );
3558 assert_eq!(
3559 serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
3560 rows
3561 );
3562 assert!(client.drain_change_batches().is_empty());
3563 assert!(client.drain_sync_intents().is_empty());
3564 assert!(client.conn.is_autocommit());
3565 client
3566 .conn
3567 .execute_batch("DROP TRIGGER fail_reset")
3568 .unwrap();
3569 client.run_log_epoch_reset("epoch-new").unwrap();
3570 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3571 assert_eq!(client.pending_commit_ids(), [commit]);
3572 }
3573 }
3574
3575 #[test]
3576 fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
3577 for subscriptions in [0, 2, 6, 12] {
3578 let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
3579 { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
3580 { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
3581 ] }), ClientLimits::default()).unwrap();
3582 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3583 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();
3584 for index in 0..32 {
3585 client
3586 .mutate(vec![Mutation::Upsert {
3587 table: "tasks".into(),
3588 values: Map::from_iter([
3589 ("id".into(), json!(format!("task-{index}"))),
3590 ("project_id".into(), json!("p1")),
3591 ]),
3592 base_version: None,
3593 }])
3594 .unwrap();
3595 }
3596 let (_, meta) = client.build_request(false).unwrap();
3597 let mut frames = vec![Frame::RespHeader {
3598 required_schema_version: None,
3599 latest_schema_version: None,
3600 log_epoch: Some("epoch-1".into()),
3601 reset_required: Some(false),
3602 }];
3603 frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
3604 Frame::PushResult {
3605 client_commit_id: id.clone(),
3606 status: PushStatus::Applied,
3607 commit_seq: Some(index as i64 + 1),
3608 results: vec![OpResult::Applied { op_index: 0 }],
3609 }
3610 }));
3611 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3612 client.acknowledged_replay_count.set(0);
3613 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3614 assert!(matches!(
3615 client.process_response(
3616 &mut transport,
3617 Message {
3618 wire_version: WIRE_VERSION,
3619 msg_kind: MsgKind::Response,
3620 frames
3621 },
3622 &meta
3623 ),
3624 SyncOutcome::Ok(_)
3625 ));
3626 assert_eq!(client.acknowledged_replay_count.get(), 32);
3627 assert_eq!(
3628 client
3629 .query(
3630 "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
3631 &[]
3632 )
3633 .unwrap()[0]["n"],
3634 json!(32)
3635 );
3636 assert_eq!(
3637 client
3638 .query(
3639 "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
3640 &[]
3641 )
3642 .unwrap()[0]["n"],
3643 json!(32)
3644 );
3645 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3646 client.acknowledged_replay_count.set(0);
3647 for index in 0..subscriptions {
3648 client
3649 .subscribe(
3650 format!("docs-{index}"),
3651 "docs".into(),
3652 vec![("project_id".into(), vec![format!("scope-{index}")])],
3653 None,
3654 )
3655 .unwrap();
3656 let sub = client.subs.len() - 1;
3657 client.subs[sub].effective =
3658 Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
3659 let segment = RowsSegment {
3660 table: "docs".into(),
3661 schema_version: 1,
3662 columns: client.schema.table("docs").unwrap().wire_columns.clone(),
3663 blocks: if index % 2 == 0 {
3664 vec![vec![ssp2::segment::SegmentRow {
3665 values: vec![
3666 Some(ColumnValue::String(format!("doc-{index}"))),
3667 Some(ColumnValue::String(format!("scope-{index}"))),
3668 ],
3669 server_version: 1,
3670 }]]
3671 } else {
3672 vec![]
3673 },
3674 };
3675 assert!(client.apply_segment(sub, &segment, true).is_ok());
3676 }
3677 assert_eq!(
3678 client
3679 .query("SELECT count(*) AS n FROM ack_writes", &[])
3680 .unwrap()[0]["n"],
3681 json!(0),
3682 "unrelated bootstrap must not restore/replay tasks"
3683 );
3684 assert_eq!(
3685 client.acknowledged_replay_count.get(),
3686 0,
3687 "unrelated bootstrap must not decode task ACKs"
3688 );
3689 assert_eq!(
3690 client
3691 .query("SELECT count(*) AS n FROM tasks", &[])
3692 .unwrap()[0]["n"],
3693 json!(32)
3694 );
3695 assert_eq!(
3696 client
3697 .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
3698 .unwrap()[0]["n"],
3699 json!(32)
3700 );
3701 }
3702 }
3703
3704 #[test]
3705 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
3706 let mut client = client();
3707 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3708 const COMMIT_COUNT: usize = 32;
3709
3710 for index in 0..COMMIT_COUNT {
3711 client
3712 .mutate(vec![Mutation::Upsert {
3713 table: "tasks".to_owned(),
3714 values: Map::from_iter([
3715 ("id".to_owned(), Value::from(format!("task-{index}"))),
3716 ("project_id".to_owned(), Value::from("project-1")),
3717 ]),
3718 base_version: None,
3719 }])
3720 .expect("queue commit");
3721 }
3722
3723 let (_, request_meta) = client.build_request(false).unwrap();
3724 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
3725 let mut frames = vec![Frame::RespHeader {
3726 required_schema_version: None,
3727 latest_schema_version: None,
3728 log_epoch: Some("epoch-1".to_owned()),
3729 reset_required: Some(false),
3730 }];
3731 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
3732 |(index, client_commit_id)| Frame::PushResult {
3733 client_commit_id: client_commit_id.clone(),
3734 status: PushStatus::Applied,
3735 commit_seq: Some(index as i64 + 1),
3736 results: vec![OpResult::Applied { op_index: 0 }],
3737 },
3738 ));
3739 let response = Message {
3740 wire_version: WIRE_VERSION,
3741 msg_kind: MsgKind::Response,
3742 frames,
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 assert!(matches!(outcome, SyncOutcome::Ok(_)));
3751 assert!(client.pending_commit_ids().is_empty());
3752 assert_eq!(
3753 client.overlay_rebuild_count.get(),
3754 1,
3755 "one response must reconcile its acknowledged commits with one overlay rebuild"
3756 );
3757 assert_eq!(
3758 client.outcome_prune_count.get(),
3759 1,
3760 "one response must enforce outcome retention once"
3761 );
3762 }
3763
3764 #[test]
3765 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
3766 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
3767 "columns": [
3768 {"name":"id", "type":"string", "nullable":false},
3769 {"name":"project_id", "type":"string", "nullable":false},
3770 {"name":"title", "type":"string", "nullable":false}
3771 ], "scopes": [{"pattern":"project:{project_id}"}],
3772 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
3773 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
3774 }] });
3775 let mut incremental =
3776 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3777 let mut reference =
3778 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3779 let tables = vec!["tasks".to_owned()];
3780 for index in 0..32 {
3781 let id = format!("task-{}", index % 8);
3782 let values = Map::from_iter([
3783 ("id".to_owned(), Value::from(id.clone())),
3784 (
3785 "project_id".to_owned(),
3786 Value::from(format!("p{}", index % 2)),
3787 ),
3788 ("title".to_owned(), Value::from(format!("needle{index}"))),
3789 ]);
3790 let payload = encode_row_json(
3791 incremental.schema.table("tasks").unwrap(),
3792 &id,
3793 &values,
3794 &incremental.encryption,
3795 )
3796 .unwrap();
3797 let change = ssp2::model::Change {
3798 table_index: 0,
3799 row_id: id,
3800 op: if index % 5 == 0 {
3801 Op::Delete
3802 } else {
3803 Op::Upsert
3804 },
3805 row_version: Some(index + 1),
3806 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
3807 row: Some(payload),
3808 };
3809 reference.overlay_dirty.set(true);
3810 for instance in [&mut incremental, &mut reference] {
3811 instance.begin_observation("mirror_test").unwrap();
3812 let mut batch = ChangeAccumulator::default();
3813 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
3814 instance
3815 .apply_commit_changes(&tables, std::slice::from_ref(&change))
3816 .unwrap();
3817 instance
3818 .rebuild_overlay_if_dirty()
3819 .expect("rebuild overlay");
3820 instance.finish_observation("mirror_test", batch).unwrap();
3821 }
3822 for sql in [
3823 "SELECT * FROM tasks ORDER BY id",
3824 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
3825 ] {
3826 assert_eq!(
3827 incremental.query(sql, &[]).unwrap(),
3828 reference.query(sql, &[]).unwrap()
3829 );
3830 }
3831 assert_eq!(incremental.local_revision(), reference.local_revision());
3832 assert_eq!(
3833 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
3834 serde_json::to_value(reference.drain_change_batches()).unwrap()
3835 );
3836 }
3837 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
3838 assert_eq!(reference.overlay_rebuild_count.get(), 32);
3839 }
3840
3841 #[test]
3842 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
3843 let cases = [
3844 (
3845 "string",
3846 vec![
3847 json!("1"),
3848 json!("01"),
3849 json!(""),
3850 json!("nul\u{0}id"),
3851 json!("é"),
3852 ],
3853 ),
3854 (
3855 "integer",
3856 vec![
3857 json!(i64::MIN),
3858 json!(-1),
3859 json!(0),
3860 json!(1),
3861 json!(i64::MAX),
3862 ],
3863 ),
3864 ("boolean", vec![json!(false), json!(true)]),
3865 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
3866 ];
3867 for (kind, ids) in cases {
3868 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3869 "columns":[{"name":"id","type":kind,"nullable":false},
3870 {"name":"project_id","type":"string","nullable":false}],
3871 "scopes":[{"pattern":"project:{project_id}"}]}]});
3872 let mut client =
3873 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
3874 for (index, id) in ids.iter().enumerate() {
3875 let table = client.schema.table("tasks").unwrap();
3876 client
3877 .write_base_row(
3878 "tasks",
3879 &vec![
3880 if kind == "integer" {
3881 Some(ColumnValue::Integer(id.as_i64().unwrap()))
3884 } else {
3885 json_to_column_value(&table.columns[0], Some(id)).unwrap()
3886 },
3887 Some(ColumnValue::String(format!("p{index}"))),
3888 ],
3889 1,
3890 )
3891 .unwrap();
3892 }
3893 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3894 for base in [true, false] {
3895 let full = if base {
3896 base_table("tasks")
3897 } else {
3898 visible_table("tasks")
3899 };
3900 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
3901 for row_id in [
3902 "1",
3903 "01",
3904 "1.0",
3905 "1.0000000000000002",
3906 "+1",
3907 "1e0",
3908 " 1",
3909 "-1",
3910 "0",
3911 "9223372036854775807",
3912 "-9223372036854775808",
3913 "true",
3914 "false",
3915 "",
3916 "nul\u{0}id",
3917 "é",
3918 "null",
3919 "{\"a\":1}",
3920 ] {
3921 let old_sql =
3922 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
3923 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
3924 let old = client
3925 .conn
3926 .prepare(&old_sql)
3927 .unwrap()
3928 .query_map([row_id], |row| row.get::<_, String>(0))
3929 .unwrap()
3930 .collect::<Result<Vec<_>, _>>()
3931 .unwrap();
3932 let new = client
3933 .conn
3934 .prepare_cached(&new_sql)
3935 .unwrap()
3936 .query_map([row_id], |row| row.get::<_, String>(0))
3937 .unwrap()
3938 .collect::<Result<Vec<_>, _>>()
3939 .unwrap();
3940 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
3941 let mut batch = ChangeAccumulator::default();
3942 assert_eq!(
3943 client.record_row_scopes(&mut batch, "tasks", row_id, base),
3944 !old.is_empty()
3945 );
3946 let expected = old.first().map(|scope| {
3947 BTreeMap::from([(
3948 "tasks".to_owned(),
3949 Some(BTreeSet::from([format!("project:{scope}")])),
3950 )])
3951 });
3952 assert_eq!(batch.tables, expected.unwrap_or_default());
3953 client
3954 .conn
3955 .execute_batch("SAVEPOINT lookup_delete")
3956 .unwrap();
3957 if base {
3958 client.delete_base_row("tasks", row_id).unwrap();
3959 } else {
3960 client
3961 .apply_outbox_ops(&[OutboxOp {
3962 upsert: false,
3963 table: "tasks".into(),
3964 row_id: row_id.into(),
3965 base_version: None,
3966 values: None,
3967 }])
3968 .expect("replay delete");
3969 }
3970 let remaining: i64 = client
3971 .conn
3972 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
3973 row.get(0)
3974 })
3975 .unwrap();
3976 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
3977 client
3978 .conn
3979 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
3980 .unwrap();
3981 client.overlay_dirty.set(false);
3982 }
3983 for (predicate, expected) in [
3984 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
3985 (predicate, "SEARCH"),
3986 ] {
3987 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
3988 let details = client
3989 .conn
3990 .prepare(&sql)
3991 .unwrap()
3992 .query_map(["1"], |row| row.get::<_, String>(3))
3993 .unwrap()
3994 .collect::<Result<Vec<_>, _>>()
3995 .unwrap();
3996 assert!(
3997 details.iter().any(|detail| detail.contains(expected)),
3998 "{kind}: {details:?}"
3999 );
4000 }
4001 }
4002 }
4003 }
4004
4005 #[test]
4006 fn primary_keys_reject_every_ineligible_column_type() {
4007 for ty in ["float", "bytes", "crdt", "blob_ref"] {
4011 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4012 "columns":[{"name":"id","type":ty,"nullable":false},
4013 {"name":"project_id","type":"string","nullable":false}],
4014 "scopes":[{"pattern":"project:{project_id}"}]}]});
4015 let error =
4016 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
4017 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
4018 Err(error) => error,
4019 };
4020 assert!(
4021 error.contains("has an ineligible column type (§2.4)"),
4022 "{ty}: {error}"
4023 );
4024 }
4025 for ty in ["string", "integer", "boolean", "json"] {
4026 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4027 "columns":[{"name":"id","type":ty,"nullable":false},
4028 {"name":"project_id","type":"string","nullable":false}],
4029 "scopes":[{"pattern":"project:{project_id}"}]}]});
4030 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
4031 .unwrap_or_else(|error| panic!("{ty}: {error}"));
4032 }
4033 }
4034
4035 #[test]
4036 fn pending_row_reconciliation_matches_full_fifo_replay() {
4037 for unique in [false, true] {
4038 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
4039 "name": name, "primaryKey": "id", "columns": [
4040 {"name":"id", "type":"string", "nullable":false},
4041 {"name":"project_id", "type":"string", "nullable":false},
4042 {"name":"title", "type":"string", "nullable":false}],
4043 "scopes": [{"pattern":"project:{project_id}"}],
4044 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
4045 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
4046 })))});
4047 let mut incremental =
4048 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4049 let mut reference =
4050 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4051 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
4052 for instance in [&mut incremental, &mut reference] {
4053 for table in &tables {
4054 for index in 0..12 {
4055 instance
4056 .write_base_row(
4057 table,
4058 &vec![
4059 Some(ColumnValue::String(format!("row{index}"))),
4060 Some(ColumnValue::String("p0".into())),
4061 Some(ColumnValue::String(format!("base{index}"))),
4062 ],
4063 1,
4064 )
4065 .unwrap();
4066 }
4067 }
4068 instance
4069 .rebuild_overlay_if_dirty()
4070 .expect("rebuild overlay");
4071 }
4072 for index in 0..80 {
4073 let mutations = (0..3)
4074 .map(|offset| {
4075 let table = tables[(index + offset) % 2].clone();
4076 let id = format!("row{}", (index + offset) % 16);
4077 if (index + offset) % 7 == 0 {
4078 Mutation::Delete {
4079 table,
4080 row_id: id,
4081 base_version: None,
4082 }
4083 } else {
4084 Mutation::Upsert {
4085 table,
4086 values: Map::from_iter([
4087 ("id".into(), Value::from(id)),
4088 ("project_id".into(), Value::from(format!("p{}", index % 3))),
4089 (
4090 "title".into(),
4091 Value::from(format!("needle{}", (index + offset) % 16)),
4092 ),
4093 ]),
4094 base_version: None,
4095 }
4096 }
4097 })
4098 .collect::<Vec<_>>();
4099 for instance in [&mut incremental, &mut reference] {
4100 instance.mutate(mutations.clone()).unwrap();
4101 }
4102 let changes = (0..3)
4105 .map(|offset| {
4106 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
4107 let id = format!("row{}", (index + offset) % 16);
4108 let values = Map::from_iter([
4109 ("id".into(), Value::from(id.clone())),
4110 (
4111 "project_id".into(),
4112 Value::from(format!("p{}", (index + 1) % 3)),
4113 ),
4114 (
4115 "title".into(),
4116 Value::from(format!("remote{index}_{offset}")),
4117 ),
4118 ]);
4119 ssp2::model::Change {
4120 table_index: table_index as u16,
4121 row_id: id.clone(),
4122 op: if (index + offset) % 5 == 0 {
4123 Op::Delete
4124 } else {
4125 Op::Upsert
4126 },
4127 row_version: Some(index as i64 + 2),
4128 scopes: vec![],
4129 row: Some(
4130 encode_row_json(
4131 incremental.schema.table(&tables[table_index]).unwrap(),
4132 &id,
4133 &values,
4134 &incremental.encryption,
4135 )
4136 .unwrap(),
4137 ),
4138 }
4139 })
4140 .collect::<Vec<_>>();
4141 reference.overlay_dirty.set(true);
4142 incremental
4143 .apply_commit_frame(&tables, &changes, None)
4144 .unwrap();
4145 reference
4146 .apply_commit_frame(&tables, &changes, None)
4147 .unwrap();
4148 for table in &tables {
4149 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
4150 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
4151 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
4152 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
4153 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
4154 }
4155 }
4156 assert_eq!(incremental.local_revision(), reference.local_revision());
4157 assert_eq!(
4158 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4159 serde_json::to_value(reference.drain_change_batches()).unwrap()
4160 );
4161 }
4162 assert_eq!(
4163 incremental.overlay_rebuild_count.get(),
4164 if unique { 81 } else { 21 }
4165 );
4166 assert_eq!(reference.overlay_rebuild_count.get(), 81);
4167 }
4168 }
4169
4170 #[test]
4171 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
4172 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
4173 "columns":[{"name":"id", "type":"string", "nullable":false},
4174 {"name":"title", "type":"string", "nullable":false}],
4175 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
4176 let mut client =
4177 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
4178 for (id, title) in [("a", "original"), ("b", "occupied")] {
4179 client
4180 .write_base_row(
4181 "tasks",
4182 &vec![
4183 Some(ColumnValue::String(id.into())),
4184 Some(ColumnValue::String(title.into())),
4185 ],
4186 1,
4187 )
4188 .unwrap();
4189 }
4190 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4191 let pending = client
4192 .mutate(vec![Mutation::Upsert {
4193 table: "tasks".into(),
4194 values: Map::from_iter([
4195 ("id".into(), Value::from("a")),
4196 ("title".into(), Value::from("available")),
4197 ]),
4198 base_version: None,
4199 }])
4200 .unwrap();
4201 assert_eq!(
4202 client
4203 .conn
4204 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4205 .get::<_, String>(
4206 0
4207 ))
4208 .unwrap(),
4209 "available"
4210 );
4211 for (title, expected) in [("available", "original"), ("occupied", "available")] {
4212 let change = ssp2::model::Change {
4213 table_index: 0,
4214 row_id: "b".into(),
4215 op: Op::Upsert,
4216 row_version: Some(2),
4217 scopes: vec![],
4218 row: Some(
4219 encode_row_json(
4220 client.schema.table("tasks").unwrap(),
4221 "b",
4222 &Map::from_iter([
4223 ("id".into(), Value::from("b")),
4224 ("title".into(), Value::from(title)),
4225 ]),
4226 &client.encryption,
4227 )
4228 .unwrap(),
4229 ),
4230 };
4231 client
4232 .apply_commit_frame(&["tasks".into()], &[change], None)
4233 .unwrap();
4234 assert_eq!(
4235 client
4236 .conn
4237 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4238 .get::<_, String>(
4239 0
4240 ))
4241 .unwrap(),
4242 expected
4243 );
4244 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
4245 }
4246 assert_eq!(client.overlay_rebuild_count.get(), 3);
4247 }
4248
4249 fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
4250 let schema = json!({ "version": 1, "tables": [
4251 { "name":"tasks", "primaryKey":"id", "columns":[
4252 {"name":"id","type":"string","nullable":false},
4253 {"name":"project_id","type":"string","nullable":false}],
4254 "scopes":[{"pattern":"project:{project_id}"}] },
4255 { "name":"catalogue", "primaryKey":"id", "columns":[
4256 {"name":"id","type":"string","nullable":false},
4257 {"name":"project_id","type":"string","nullable":false},
4258 {"name":"title","type":"string","nullable":false}],
4259 "scopes":[{"pattern":"project:{project_id}"}],
4260 "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
4261 ] });
4262 let mut client =
4263 SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
4264 if unchanged_rows > 0 {
4265 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();
4266 }
4267 client.rebuild_overlay().expect("rebuild overlay");
4268 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();
4269 let mut elapsed = Vec::new();
4270 for round in 1..=rounds {
4271 let pending = client
4272 .mutate(vec![Mutation::Upsert {
4273 table: "tasks".into(),
4274 values: Map::from_iter([
4275 ("id".into(), json!("t1")),
4276 ("project_id".into(), json!("p1")),
4277 ]),
4278 base_version: None,
4279 }])
4280 .unwrap();
4281 let started = std::time::Instant::now();
4282 client
4283 .write_base_row(
4284 "tasks",
4285 &vec![
4286 Some(ColumnValue::String("t1".into())),
4287 Some(ColumnValue::String("p1".into())),
4288 ],
4289 round,
4290 )
4291 .unwrap();
4292 let mut report = SyncReport::default();
4293 client
4294 .handle_push_results(
4295 &[Frame::PushResult {
4296 client_commit_id: pending.clone(),
4297 status: PushStatus::Applied,
4298 commit_seq: Some(round),
4299 results: vec![OpResult::Applied { op_index: 0 }],
4300 }],
4301 &HashSet::from([pending.as_str()]),
4302 &HashMap::new(),
4303 &mut report,
4304 Some(&pending),
4305 )
4306 .unwrap();
4307 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4308 elapsed.push(started.elapsed().as_nanos());
4309 }
4310 assert_eq!(
4311 client
4312 .conn
4313 .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
4314 .unwrap(),
4315 0,
4316 "unchanged catalogue must never be copied"
4317 );
4318 assert_eq!(
4319 client
4320 .conn
4321 .query_row(
4322 "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
4323 [],
4324 |r| r.get::<_, i64>(0)
4325 )
4326 .unwrap(),
4327 unchanged_rows
4328 );
4329 assert_eq!(
4330 client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
4331 json!(rounds)
4332 );
4333 elapsed
4334 }
4335
4336 #[test]
4337 fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
4338 let elapsed = measure_scoped_overlay(100_000, 1);
4339 eprintln!(
4340 "table-scoped overlay with 100k unchanged rows: {} ns",
4341 elapsed[0]
4342 );
4343 }
4344
4345 #[test]
4346 #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
4347 fn benchmark_scoped_overlay() {
4348 for rows in [0, 100_000, 200_000] {
4349 let mut elapsed = measure_scoped_overlay(rows, 30);
4350 elapsed.sort_unstable();
4351 eprintln!(
4352 "{}",
4353 json!({"unchangedRows": rows, "rounds": elapsed.len(),
4354 "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
4355 "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
4356 );
4357 }
4358 }
4359
4360 #[test]
4361 fn released_context_discards_in_flight_ack_and_server_rows() {
4362 for barrier in [
4363 "preflight",
4364 "purge",
4365 "rebootstrap",
4366 "subscription",
4367 "failed-purge-replay",
4368 "failed-purge-publish",
4369 "failed-rebootstrap",
4370 ] {
4371 let mut client = client();
4372 client.set_meta(LOG_EPOCH_KEY, "epoch");
4373 client
4374 .subscribe(
4375 "tasks".into(),
4376 "tasks".into(),
4377 vec![("project_id".into(), vec!["p1".into()])],
4378 None,
4379 )
4380 .unwrap();
4381 let commit = client
4382 .mutate(vec![Mutation::Upsert {
4383 table: "tasks".into(),
4384 values: Map::from_iter([
4385 ("id".into(), json!("local")),
4386 ("project_id".into(), json!("p1")),
4387 ]),
4388 base_version: None,
4389 }])
4390 .unwrap();
4391 let prepared = client.prepare_sync_round(false).unwrap();
4392 client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
4393 let payload = encode_row_json(
4394 &client.schema.tables[0],
4395 "server",
4396 &Map::from_iter([
4397 ("id".into(), json!("server")),
4398 ("project_id".into(), json!("p1")),
4399 ]),
4400 &client.encryption,
4401 )
4402 .unwrap();
4403 let completed = crate::CompletedSyncRound {
4404 prepared,
4405 exchange: crate::round::ExchangeResult::Reply {
4406 transport_failed: false,
4407 downloads: Default::default(),
4408 response: Ok(Message {
4409 wire_version: WIRE_VERSION,
4410 msg_kind: MsgKind::Response,
4411 frames: vec![
4412 Frame::RespHeader {
4413 required_schema_version: None,
4414 latest_schema_version: None,
4415 log_epoch: Some("epoch".into()),
4416 reset_required: Some(false),
4417 },
4418 Frame::PushResult {
4419 client_commit_id: commit.clone(),
4420 status: PushStatus::Applied,
4421 commit_seq: Some(1),
4422 results: vec![OpResult::Applied { op_index: 0 }],
4423 },
4424 Frame::SubStart {
4425 id: "tasks".into(),
4426 status: SubStatus::Active,
4427 reason_code: String::new(),
4428 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
4429 bootstrap: true,
4430 },
4431 Frame::Commit {
4432 commit_seq: 1,
4433 created_at_ms: 0,
4434 actor_id: "actor".into(),
4435 tables: vec!["tasks".into()],
4436 changes: vec![ssp2::model::Change {
4437 table_index: 0,
4438 row_id: "server".into(),
4439 op: Op::Upsert,
4440 row_version: Some(1),
4441 scopes: vec![("project_id".into(), "p1".into())],
4442 row: Some(payload),
4443 }],
4444 },
4445 Frame::SubEnd {
4446 next_cursor: 1,
4447 bootstrap_state: None,
4448 },
4449 ],
4450 }),
4451 },
4452 };
4453 match barrier {
4454 "preflight" => client.begin_security_preflight(),
4455 "purge" => {
4456 client
4457 .purge_local_data(&LocalDataPurgeInput {
4458 purge_id: "pending-round".into(),
4459 targets: vec![LocalDataPurgeTarget {
4460 table: "tasks".into(),
4461 selectors: BTreeMap::from([(
4462 "project_id".into(),
4463 vec!["p1".into()],
4464 )]),
4465 }],
4466 })
4467 .unwrap();
4468 }
4469 "rebootstrap" => {
4470 client
4471 .rebootstrap_local_data(&LocalDataRebootstrapInput {
4472 rebootstrap_id: "pending-round".into(),
4473 })
4474 .unwrap();
4475 }
4476 "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
4477 let trigger = if barrier == "failed-purge-replay" {
4478 "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4479 } else {
4480 "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4481 };
4482 client.conn.execute_batch(trigger).unwrap();
4483 let revision = client.local_revision();
4484 if barrier == "failed-rebootstrap" {
4485 assert!(client
4486 .rebootstrap_local_data(&LocalDataRebootstrapInput {
4487 rebootstrap_id: "failed-round".into(),
4488 })
4489 .is_err());
4490 } else {
4491 assert!(client
4492 .purge_local_data(&LocalDataPurgeInput {
4493 purge_id: "failed-round".into(),
4494 targets: vec![LocalDataPurgeTarget {
4495 table: "tasks".into(),
4496 selectors: BTreeMap::from([(
4497 "project_id".into(),
4498 vec!["p1".into()]
4499 )]),
4500 }],
4501 })
4502 .is_err());
4503 }
4504 assert_eq!(client.local_revision(), revision);
4505 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
4506 client
4507 .conn
4508 .execute_batch("DROP TRIGGER fail_local")
4509 .unwrap();
4510 let applied = client.apply_sync_round(completed);
4511 assert!(
4512 matches!(
4513 applied,
4514 crate::AppliedSyncRound::Complete {
4515 outcome: SyncOutcome::Ok(_),
4516 ..
4517 }
4518 ),
4519 "{barrier}"
4520 );
4521 assert!(client.pending_commit_ids().is_empty());
4522 assert_eq!(
4523 client
4524 .query("SELECT id FROM tasks WHERE id = 'server'", &[])
4525 .unwrap()
4526 .len(),
4527 1
4528 );
4529 continue;
4530 }
4531 "subscription" => {
4532 client.unsubscribe("tasks");
4533 client
4534 .subscribe(
4535 "tasks".into(),
4536 "tasks".into(),
4537 vec![("project_id".into(), vec!["p2".into()])],
4538 None,
4539 )
4540 .unwrap();
4541 }
4542 _ => unreachable!(),
4543 }
4544 let pending_before_reply = client.pending_commit_ids();
4545 let revision = client.local_revision();
4546 let applied = client.apply_sync_round(completed);
4547 assert!(
4548 matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
4549 );
4550 assert_eq!(client.local_revision(), revision);
4551 assert!(client
4552 .conn
4553 .query_row(
4554 "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
4555 [],
4556 |r| r.get::<_, bool>(0)
4557 )
4558 .unwrap());
4559 assert_eq!(client.pending_commit_ids(), pending_before_reply);
4560 }
4561 }
4562
4563 #[test]
4564 fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
4565 struct DeadlineRecording {
4566 budget: std::time::Duration,
4567 seen: Vec<Option<std::time::Instant>>,
4568 }
4569 impl Transport for DeadlineRecording {
4570 fn round_deadline(&self) -> Option<std::time::Duration> {
4571 Some(self.budget)
4572 }
4573 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4574 self.seen.push(deadline);
4575 }
4576 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4577 Err(TransportError::new("transport.failed", "injected"))
4578 }
4579 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4580 self.sync(request)
4581 }
4582 fn download_segment(
4583 &mut self,
4584 _: &SegmentRequest,
4585 _: &mut dyn FnMut(u64),
4586 ) -> Result<Vec<u8>, TransportError> {
4587 Err(TransportError::new("transport.failed", "injected"))
4588 }
4589 fn blob_upload_grant(
4590 &mut self,
4591 _: &str,
4592 _: u64,
4593 _: Option<&str>,
4594 ) -> Result<crate::BlobUploadGrant, TransportError> {
4595 Ok(crate::BlobUploadGrant::Present)
4596 }
4597 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4598 Ok(())
4599 }
4600 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4601 Ok(())
4602 }
4603 fn realtime_close(&mut self) -> Result<(), TransportError> {
4604 Ok(())
4605 }
4606 }
4607
4608 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4609 {"name":"id","type":"string","nullable":false},
4610 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4611 let mut client =
4612 SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
4613 client.set_meta(LOG_EPOCH_KEY, "epoch");
4614 client
4615 .upload_blob(b"pending upload", Some("text/plain".into()), None)
4616 .unwrap();
4617
4618 let mut transport = DeadlineRecording {
4619 budget: std::time::Duration::from_secs(30),
4620 seen: Vec::new(),
4621 };
4622 let prepared = client.prepare_sync_round(false).unwrap();
4623 assert_eq!(prepared.uploads.len(), 1, "one staged upload");
4624 let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
4626 crate::AppliedSyncRound::Continue(prepared) => prepared,
4627 crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
4628 };
4629 let completed = prepared.exchange(&mut transport);
4631 let anchor = completed
4632 .prepared
4633 .round_deadline_at
4634 .expect("the upload round anchors a deadline");
4635 assert!(matches!(
4636 client.apply_sync_round(completed),
4637 crate::AppliedSyncRound::Complete { .. }
4638 ));
4639 assert_eq!(transport.seen.len(), 4, "one scope per exchange");
4640 assert_eq!(
4641 transport.seen[0],
4642 Some(anchor),
4643 "the upload anchors the round"
4644 );
4645 assert_eq!(
4646 transport.seen[1], None,
4647 "the upload exchange clears its scope"
4648 );
4649 assert_eq!(
4650 transport.seen[2],
4651 Some(anchor),
4652 "a continuation reuses the carried anchor instead of refreshing it"
4653 );
4654 assert_eq!(transport.seen[3], None, "the continuation clears its scope");
4655
4656 transport.budget = std::time::Duration::from_secs(60);
4659 let prepared = client.prepare_sync_round(false).unwrap();
4660 let completed = prepared.exchange(&mut transport);
4661 let fresh = completed
4662 .prepared
4663 .round_deadline_at
4664 .expect("the next round anchors a deadline");
4665 client.apply_sync_round(completed);
4666 assert!(
4667 fresh >= anchor + std::time::Duration::from_secs(30),
4668 "the next round anchors a fresh deadline from the new budget"
4669 );
4670 assert_eq!(transport.seen[4], Some(fresh));
4671 assert_eq!(
4672 transport.seen[5], None,
4673 "the new round clears its scope too"
4674 );
4675 }
4676
4677 #[derive(Default)]
4680 struct RoundDeadlineProbe {
4681 budget: Option<std::time::Duration>,
4682 budget_queries: std::cell::Cell<usize>,
4683 scoped: Vec<Option<std::time::Instant>>,
4684 }
4685
4686 impl Transport for RoundDeadlineProbe {
4687 fn round_deadline(&self) -> Option<std::time::Duration> {
4688 self.budget_queries.set(self.budget_queries.get() + 1);
4689 self.budget
4690 }
4691 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4692 self.scoped.push(deadline);
4693 }
4694 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4695 Err(TransportError::new("transport.failed", "injected"))
4696 }
4697 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4698 self.sync(request)
4699 }
4700 fn download_segment(
4701 &mut self,
4702 _: &SegmentRequest,
4703 _: &mut dyn FnMut(u64),
4704 ) -> Result<Vec<u8>, TransportError> {
4705 Err(TransportError::new("transport.failed", "injected"))
4706 }
4707 fn blob_upload_grant(
4708 &mut self,
4709 _: &str,
4710 _: u64,
4711 _: Option<&str>,
4712 ) -> Result<crate::BlobUploadGrant, TransportError> {
4713 Ok(crate::BlobUploadGrant::Present)
4714 }
4715 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4716 Ok(())
4717 }
4718 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4719 Ok(())
4720 }
4721 fn realtime_close(&mut self) -> Result<(), TransportError> {
4722 Ok(())
4723 }
4724 }
4725
4726 #[test]
4727 fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
4728 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4729 {"name":"id","type":"string","nullable":false},
4730 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4731 let mut client =
4732 SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
4733 client.set_meta(LOG_EPOCH_KEY, "epoch");
4734 client
4735 .upload_blob(b"pending upload", Some("text/plain".into()), None)
4736 .unwrap();
4737 client
4738 .conn
4739 .execute_batch("DELETE FROM _syncular_blobs")
4740 .unwrap();
4741 let prepared = client.prepare_sync_round(false).unwrap();
4742 assert!(
4743 prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
4744 "the staged body must fail locally"
4745 );
4746 let mut transport = RoundDeadlineProbe {
4747 budget: Some(std::time::Duration::from_secs(30)),
4748 ..Default::default()
4749 };
4750 let completed = prepared.exchange(&mut transport);
4751 assert!(matches!(
4752 completed.exchange,
4753 crate::round::ExchangeResult::Upload { result: Err(_), .. }
4754 ));
4755 assert_eq!(
4756 transport.budget_queries.get(),
4757 0,
4758 "a round with no network work never asks for a budget"
4759 );
4760 assert_eq!(transport.scoped, vec![None]);
4761 }
4762
4763 #[test]
4764 fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
4765 struct HugeBudget;
4766 impl Transport for HugeBudget {
4767 fn round_deadline(&self) -> Option<std::time::Duration> {
4768 Some(std::time::Duration::MAX)
4769 }
4770 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4771 panic!("no network for an invalid budget")
4772 }
4773 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4774 self.sync(request)
4775 }
4776 fn download_segment(
4777 &mut self,
4778 _: &SegmentRequest,
4779 _: &mut dyn FnMut(u64),
4780 ) -> Result<Vec<u8>, TransportError> {
4781 panic!("no network for an invalid budget")
4782 }
4783 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4784 Ok(())
4785 }
4786 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4787 Ok(())
4788 }
4789 fn realtime_close(&mut self) -> Result<(), TransportError> {
4790 Ok(())
4791 }
4792 }
4793
4794 let mut client = client();
4795 client.set_meta(LOG_EPOCH_KEY, "epoch");
4796 let prepared = client.prepare_sync_round(false).unwrap();
4797 let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
4798 crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
4799 crate::AppliedSyncRound::Continue(_) => {
4800 panic!("an unrepresentable budget must not continue")
4801 }
4802 };
4803 match outcome {
4804 SyncOutcome::Failed {
4805 error_code,
4806 message,
4807 ..
4808 } => {
4809 assert_eq!(error_code, "sync.invalid_request");
4810 assert_eq!(message, "transport round deadline is out of range");
4811 }
4812 _ => panic!("an unrepresentable budget must fail synchronously"),
4813 }
4814 }
4815
4816 #[test]
4817 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
4818 for failure in ["visible", "revision", "commit"] {
4819 let mut client = client();
4820 let id = client
4821 .mutate(vec![Mutation::Upsert {
4822 table: "tasks".into(),
4823 values: Map::from_iter([
4824 ("id".into(), Value::from("t1")),
4825 ("project_id".into(), Value::from("pending")),
4826 ]),
4827 base_version: None,
4828 }])
4829 .unwrap();
4830 client.drain_change_batches();
4831 let revision = client.local_revision();
4832 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
4833 let trigger = match failure {
4834 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
4835 "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"),
4836 _ => "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(),
4837 };
4838 client.conn.execute_batch(&trigger).unwrap();
4839 let change = ssp2::model::Change {
4840 table_index: 0,
4841 row_id: "t1".into(),
4842 op: Op::Upsert,
4843 row_version: Some(2),
4844 scopes: vec![],
4845 row: Some(
4846 encode_row_json(
4847 client.schema.table("tasks").unwrap(),
4848 "t1",
4849 &Map::from_iter([
4850 ("id".into(), Value::from("t1")),
4851 ("project_id".into(), Value::from("remote")),
4852 ]),
4853 &client.encryption,
4854 )
4855 .unwrap(),
4856 ),
4857 };
4858 assert!(client
4859 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
4860 .is_err());
4861 assert!(client.conn.is_autocommit());
4862 assert!(!client.overlay_dirty.get());
4863 assert_eq!(client.local_revision(), revision);
4864 assert!(client.drain_change_batches().is_empty());
4865 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
4866 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
4867 assert!(client
4868 .query("SELECT * FROM _syncular_base_tasks", &[])
4869 .unwrap()
4870 .is_empty());
4871 client
4872 .conn
4873 .execute_batch("DROP TRIGGER fail_pending")
4874 .unwrap();
4875 client
4876 .apply_commit_frame(&["tasks".into()], &[change], None)
4877 .unwrap();
4878 assert_eq!(client.local_revision(), revision + 1);
4879 assert_eq!(client.pending_commit_ids(), vec![id]);
4880 let mut expected = before[0].clone();
4883 expected.insert("_syncular_version".to_owned(), Value::from(2));
4884 assert_eq!(
4885 client.query("SELECT * FROM tasks", &[]).unwrap(),
4886 vec![expected]
4887 );
4888 assert_eq!(
4889 client
4890 .conn
4891 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
4892 .get::<_, String>(0))
4893 .unwrap(),
4894 "remote"
4895 );
4896 }
4897 }
4898
4899 #[test]
4900 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
4901 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
4902 "columns": [
4903 {"name":"id", "type":"string", "nullable":false},
4904 {"name":"project_id", "type":"string", "nullable":false},
4905 {"name":"title", "type":"string", "nullable":false}],
4906 "scopes": [{"pattern":"project:{project_id}"}],
4907 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
4908 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
4909 let mut incremental =
4910 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
4911 let mut reference =
4912 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
4913 for instance in [&mut incremental, &mut reference] {
4914 for index in 0..16 {
4915 instance
4916 .write_base_row(
4917 "tasks",
4918 &vec![
4919 Some(ColumnValue::String(format!("task{index}"))),
4920 Some(ColumnValue::String("p0".to_owned())),
4921 Some(ColumnValue::String(format!("base{index}"))),
4922 ],
4923 1,
4924 )
4925 .unwrap();
4926 }
4927 instance
4928 .rebuild_overlay_if_dirty()
4929 .expect("rebuild overlay");
4930 instance.overlay_rebuild_count.set(0);
4931 }
4932 for index in 0..100 {
4933 let mutations = (0..1 + index % 4)
4934 .map(|offset| {
4935 let id = format!("task{}", (index + offset) % 24);
4936 if (index + offset) % 7 == 0 {
4937 Mutation::Delete {
4938 table: "tasks".to_owned(),
4939 row_id: id,
4940 base_version: None,
4941 }
4942 } else {
4943 Mutation::Upsert {
4944 table: "tasks".to_owned(),
4945 values: Map::from_iter([
4946 ("id".to_owned(), Value::from(id)),
4947 (
4948 "project_id".to_owned(),
4949 Value::from(format!("p{}", index % 2)),
4950 ),
4951 (
4952 "title".to_owned(),
4953 Value::from(format!("needle{}", (index + offset) % 24)),
4954 ),
4955 ]),
4956 base_version: None,
4957 }
4958 }
4959 })
4960 .collect::<Vec<_>>();
4961 reference.overlay_dirty.set(true);
4962 incremental.mutate(mutations.clone()).unwrap();
4963 reference.mutate(mutations).unwrap();
4964 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
4965 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
4966 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
4967 }
4968 assert_eq!(incremental.local_revision(), reference.local_revision());
4969 assert_eq!(
4970 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4971 serde_json::to_value(reference.drain_change_batches()).unwrap()
4972 );
4973 }
4974 assert_eq!(incremental.outbox.len(), 100);
4975 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4976 assert_eq!(reference.overlay_rebuild_count.get(), 100);
4977 }
4978
4979 #[test]
4980 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
4981 for fail_at in ["outbox", "revision"] {
4982 let mut client = client();
4983 let values = Map::from_iter([
4984 ("id".to_owned(), Value::from("t1")),
4985 ("project_id".to_owned(), Value::from("p1")),
4986 ]);
4987 let mutation = Mutation::Upsert {
4988 table: "tasks".to_owned(),
4989 values,
4990 base_version: None,
4991 };
4992 let trigger = if fail_at == "outbox" {
4993 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
4994 } else {
4995 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
4996 };
4997 client.conn.execute_batch(&trigger).unwrap();
4998 let revision = client.local_revision();
4999 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
5000 assert_eq!(client.local_revision(), revision);
5001 assert!(client.outbox.is_empty());
5002 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5003 assert_eq!(
5004 client
5005 .conn
5006 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5007 .get::<_, i64>(0))
5008 .unwrap(),
5009 0
5010 );
5011 assert!(client.drain_change_batches().is_empty());
5012 assert!(!client.overlay_dirty.get());
5013 client
5014 .conn
5015 .execute_batch("DROP TRIGGER fail_append")
5016 .unwrap();
5017 client.mutate(vec![mutation]).unwrap();
5018 assert_eq!(client.outbox.len(), 1);
5019 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
5020 }
5021 }
5022
5023 #[test]
5024 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
5025 let mut client = client();
5026 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
5027 let values = Map::from_iter([
5028 ("id".to_owned(), Value::from("t1")),
5029 ("project_id".to_owned(), Value::from("p1")),
5030 ]);
5031 let payload = encode_row_json(
5032 client.schema.table("tasks").unwrap(),
5033 "t1",
5034 &values,
5035 &client.encryption,
5036 )
5037 .unwrap();
5038 let first = ssp2::model::Change {
5039 table_index: 0,
5040 row_id: "t1".to_owned(),
5041 op: Op::Upsert,
5042 row_version: Some(1),
5043 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
5044 row: Some(payload),
5045 };
5046 let mut invalid = first.clone();
5047 invalid.table_index = 1;
5048 let revision = client.local_revision();
5049 client.begin_observation("failed_mirror").unwrap();
5050 assert!(client
5051 .apply_commit_changes(&tables, &[first, invalid])
5052 .is_err());
5053 client.rollback_observation("failed_mirror");
5054 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
5055 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5056 assert_eq!(
5057 client
5058 .conn
5059 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
5060 .get::<_, i64>(
5061 0
5062 ))
5063 .unwrap(),
5064 0
5065 );
5066 assert_eq!(client.local_revision(), revision);
5067 assert!(client.drain_change_batches().is_empty());
5068 }
5069
5070 #[test]
5071 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
5072 for failure in ["journal", "revision", "commit", "later-frame"] {
5073 let mut client = client();
5074 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5075 for id in ["first", "second"] {
5076 client
5077 .mutate(vec![Mutation::Upsert {
5078 table: "tasks".to_owned(),
5079 values: Map::from_iter([
5080 ("id".to_owned(), Value::from(id)),
5081 ("project_id".to_owned(), Value::from("p1")),
5082 ]),
5083 base_version: None,
5084 }])
5085 .unwrap();
5086 }
5087 let (_, meta) = client.build_request(false).unwrap();
5088 client
5090 .mutate(vec![Mutation::Upsert {
5091 table: "tasks".to_owned(),
5092 values: Map::from_iter([
5093 ("id".to_owned(), Value::from("later")),
5094 ("project_id".to_owned(), Value::from("p1")),
5095 ]),
5096 base_version: None,
5097 }])
5098 .unwrap();
5099 let ids = client.pending_commit_ids();
5100 let rows = client
5101 .query("SELECT * FROM tasks ORDER BY id", &[])
5102 .unwrap();
5103 let revision = client.local_revision();
5104 client.drain_change_batches();
5105 if failure != "later-frame" {
5106 client.conn.execute_batch(match failure {
5107 "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",
5108 "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",
5109 _ => "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",
5110 }).unwrap();
5111 }
5112 let mut response = Message {
5113 wire_version: WIRE_VERSION,
5114 msg_kind: MsgKind::Response,
5115 frames: vec![Frame::RespHeader {
5116 required_schema_version: None,
5117 latest_schema_version: None,
5118 log_epoch: Some("epoch-1".to_owned()),
5119 reset_required: Some(false),
5120 }],
5121 };
5122 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
5123 response.frames.push(Frame::PushResult {
5124 client_commit_id: id.to_owned(),
5125 status: PushStatus::Applied,
5126 commit_seq: Some(1),
5127 results: vec![OpResult::Applied { op_index: 0 }],
5128 });
5129 }
5130 if failure == "later-frame" {
5131 response.frames.push(Frame::Error {
5132 code: "sync.invalid_request".to_owned(),
5133 message: "later error".to_owned(),
5134 category: "protocol".to_owned(),
5135 retryable: false,
5136 recommended_action: "retry".to_owned(),
5137 details: None,
5138 });
5139 }
5140 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5141 let outcome = client.process_response(&mut transport, response.clone(), &meta);
5142 let expected = match failure {
5145 "later-frame" => "sync.invalid_request",
5146 "commit" => "storage.failed",
5147 _ => "client.outcome_persistence_failed",
5148 };
5149 assert!(
5150 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5151 "{failure}: {outcome:?}"
5152 );
5153 if failure == "later-frame" {
5154 assert_eq!(client.pending_commit_ids(), ids[2..]);
5155 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5156 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
5157 let batches = client.drain_change_batches();
5158 assert_eq!(batches.len(), 1);
5159 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
5160 assert_eq!(client.local_revision(), revision + 1);
5161 response.frames.pop();
5162 } else {
5163 assert_eq!(client.pending_commit_ids(), ids);
5164 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5165 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
5166 assert_eq!(client.local_revision(), revision);
5167 assert!(client.drain_change_batches().is_empty());
5168 assert_eq!(
5169 client
5170 .query("SELECT * FROM tasks ORDER BY id", &[])
5171 .unwrap(),
5172 rows
5173 );
5174 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
5175 }
5176 for frame in &mut response.frames {
5177 if let Frame::PushResult { status, .. } = frame {
5178 *status = PushStatus::Cached;
5179 }
5180 }
5181 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
5182 else {
5183 panic!("retry failed");
5184 };
5185 assert_eq!(
5186 report.applied,
5187 if failure == "later-frame" {
5188 vec![]
5189 } else {
5190 ids[..2].to_vec()
5191 }
5192 );
5193 assert_eq!(client.pending_commit_ids(), ids[2..]);
5194 let batches = client.drain_change_batches();
5195 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
5196 if let Some(batch) = batches.first() {
5197 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
5198 }
5199 assert_eq!(client.local_revision(), revision + 1);
5200 }
5201 }
5202
5203 #[test]
5204 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
5205 for failure in ["revision", "journal", "commit"] {
5206 for status in ["applied", "cached", "rejected", "conflict"] {
5207 let mut client = client();
5208 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5209 for id in ["first", "later"] {
5210 client
5211 .mutate(vec![Mutation::Upsert {
5212 table: "tasks".to_owned(),
5213 values: Map::from_iter([
5214 ("id".to_owned(), Value::from(id)),
5215 ("project_id".to_owned(), Value::from("p1")),
5216 ]),
5217 base_version: None,
5218 }])
5219 .unwrap();
5220 }
5221 let (_, meta) = client.build_request(false).unwrap();
5222 let ids = client.pending_commit_ids();
5223 let rows = client
5224 .query("SELECT * FROM tasks ORDER BY id", &[])
5225 .unwrap();
5226 let revision = client.local_revision();
5227 client.drain_change_batches();
5228 let (push_status, results) = match status {
5229 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
5230 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
5231 "conflict" => (
5232 PushStatus::Rejected,
5233 vec![OpResult::Conflict {
5234 op_index: 0,
5235 code: "sync.version_conflict".to_owned(),
5236 message: "conflict".to_owned(),
5237 server_version: 1,
5238 server_row: encode_row_json(
5239 client.schema.table("tasks").unwrap(),
5240 "first",
5241 &Map::from_iter([
5242 ("id".to_owned(), Value::from("first")),
5243 ("project_id".to_owned(), Value::from("p1")),
5244 ]),
5245 &client.encryption,
5246 )
5247 .unwrap(),
5248 conflict_columns: Vec::new(),
5249 }],
5250 ),
5251 _ => (
5252 PushStatus::Rejected,
5253 vec![OpResult::Error {
5254 op_index: 0,
5255 code: "sync.validation_failed".to_owned(),
5256 message: "rejected".to_owned(),
5257 retryable: false,
5258 }],
5259 ),
5260 };
5261 let response = Message {
5262 wire_version: WIRE_VERSION,
5263 msg_kind: MsgKind::Response,
5264 frames: vec![
5265 Frame::RespHeader {
5266 required_schema_version: None,
5267 latest_schema_version: None,
5268 log_epoch: Some("epoch-1".to_owned()),
5269 reset_required: Some(false),
5270 },
5271 Frame::PushResult {
5272 client_commit_id: ids[0].clone(),
5273 status: push_status,
5274 commit_seq: Some(1),
5275 results,
5276 },
5277 ],
5278 };
5279 client.conn.execute_batch(match failure {
5280 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
5281 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
5282 _ => "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",
5283 }).unwrap();
5284 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5285 let outcome = client.process_response(&mut transport, response.clone(), &meta);
5286 assert_eq!(
5287 client.pending_commit_ids(),
5288 ids,
5289 "{failure}/{status}: pending identity"
5290 );
5291 assert_eq!(
5292 client.local_revision(),
5293 revision,
5294 "{failure}/{status}: revision"
5295 );
5296 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5297 assert!(client.conflicts().is_empty());
5298 assert!(client.rejections().is_empty());
5299 assert!(client.drain_change_batches().is_empty());
5300 assert_eq!(
5301 client
5302 .query("SELECT * FROM tasks ORDER BY id", &[])
5303 .unwrap(),
5304 rows
5305 );
5306 let expected = if failure == "commit" {
5309 "storage.failed"
5310 } else {
5311 "client.outcome_persistence_failed"
5312 };
5313 assert!(
5314 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5315 "{failure}/{status}: failed round"
5316 );
5317 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
5318 assert!(matches!(
5319 client.process_response(&mut transport, response, &meta),
5320 SyncOutcome::Ok(_)
5321 ));
5322 assert_eq!(client.pending_commit_ids(), ids[1..]);
5323 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5324 assert_eq!(client.drain_change_batches().len(), 1);
5325 }
5326 }
5327 }
5328
5329 #[test]
5330 fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
5331 let mut client = client();
5332 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5333 for index in 0..4 {
5334 client
5335 .mutate(vec![Mutation::Upsert {
5336 table: "tasks".to_owned(),
5337 values: Map::from_iter([
5338 ("id".to_owned(), Value::from(format!("task-{index}"))),
5339 ("project_id".to_owned(), Value::from("project-1")),
5340 ]),
5341 base_version: None,
5342 }])
5343 .expect("queue commit");
5344 }
5345
5346 let (_, request_meta) = client.build_request(false).unwrap();
5347 let ids = &request_meta.pushed_ids;
5348 assert_eq!(ids.len(), 4);
5349 let response = Message {
5350 wire_version: WIRE_VERSION,
5351 msg_kind: MsgKind::Response,
5352 frames: vec![
5353 Frame::RespHeader {
5354 required_schema_version: None,
5355 latest_schema_version: None,
5356 log_epoch: Some("epoch-1".to_owned()),
5357 reset_required: Some(false),
5358 },
5359 Frame::PushResult {
5360 client_commit_id: ids[0].clone(),
5361 status: PushStatus::Applied,
5362 commit_seq: Some(1),
5363 results: vec![OpResult::Applied { op_index: 0 }],
5364 },
5365 Frame::PushResult {
5366 client_commit_id: ids[1].clone(),
5367 status: PushStatus::Cached,
5368 commit_seq: Some(2),
5369 results: vec![OpResult::Applied { op_index: 0 }],
5370 },
5371 Frame::PushResult {
5372 client_commit_id: ids[2].clone(),
5373 status: PushStatus::Rejected,
5374 commit_seq: None,
5375 results: vec![OpResult::Error {
5376 op_index: 0,
5377 code: "sync.validation_failed".to_owned(),
5378 message: "rejected".to_owned(),
5379 retryable: false,
5380 }],
5381 },
5382 Frame::PushResult {
5383 client_commit_id: ids[3].clone(),
5384 status: PushStatus::Rejected,
5385 commit_seq: None,
5386 results: vec![OpResult::Error {
5387 op_index: 0,
5388 code: "sync.idempotency_cache_miss".to_owned(),
5389 message: "retry".to_owned(),
5390 retryable: true,
5391 }],
5392 },
5393 ],
5394 };
5395 let mut transport =
5396 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
5397
5398 client.overlay_rebuild_count.set(0);
5399 client.outcome_prune_count.set(0);
5400 let outcome = client.process_response(&mut transport, response, &request_meta);
5401 let SyncOutcome::Ok(report) = outcome else {
5402 panic!("mixed push-result response failed");
5403 };
5404
5405 assert_eq!(report.applied, ids[..2]);
5406 assert_eq!(report.rejected, ids[2..3]);
5407 assert_eq!(report.retryable, ids[3..4]);
5408 assert_eq!(client.pending_commit_ids(), ids[3..4]);
5409 assert_eq!(
5410 client
5411 .query("SELECT id FROM tasks ORDER BY id", &[])
5412 .expect("query visible overlay"),
5413 ["task-0", "task-1", "task-3"]
5414 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
5415 .to_vec()
5416 );
5417 assert_eq!(client.overlay_rebuild_count.get(), 2);
5419 assert_eq!(client.outcome_prune_count.get(), 1);
5420 }
5421
5422 fn replay_schema(fts: bool) -> Value {
5423 json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
5424 {"name":"id","type":"string","nullable":false},
5425 {"name":"project_id","type":"string","nullable":false},
5426 {"name":"title","type":"string","nullable":false},
5427 {"name":"done","type":"boolean","nullable":false}
5428 ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
5429 }
5430
5431 #[test]
5432 fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
5433 for schema_reset in [false, true] {
5434 let mut schema = replay_schema(false);
5435 let mut client =
5436 SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
5437 let id = client
5438 .mutate(vec![Mutation::Upsert {
5439 table: "tasks".into(),
5440 values: Map::from_iter([
5441 ("id".into(), json!("queued")),
5442 ("project_id".into(), json!("p1")),
5443 ("title".into(), json!("old")),
5444 ("done".into(), json!(false)),
5445 ]),
5446 base_version: None,
5447 }])
5448 .unwrap();
5449 client.set_meta(LOG_EPOCH_KEY, "old");
5450 let revision = client.local_revision();
5451 let needed = client.sync_needed;
5452 client.drain_change_batches();
5453 client.drain_sync_intents();
5454 client.conn.execute_batch("CREATE TRIGGER refuse_revision BEFORE INSERT ON _syncular_meta WHEN NEW.key='localRevision' BEGIN SELECT RAISE(ABORT,'reset completion failed'); END;").unwrap();
5455 if schema_reset {
5456 schema["version"] = json!(2);
5457 schema["tables"][0]["columns"]
5458 .as_array_mut()
5459 .unwrap()
5460 .retain(|column| column["name"] != "title");
5461 client.schema = parse_schema_json(&schema).unwrap();
5462 assert!(client.run_schema_reset().is_err());
5463 } else {
5464 assert!(client.run_log_epoch_reset("new").is_err());
5465 }
5466 assert!(!client.upgrading);
5467 assert_eq!(client.sync_needed, needed);
5468 assert_eq!(client.local_revision(), revision);
5469 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5470 assert!(client.rejections.is_empty());
5471 assert!(client.commit_outcome(&id).unwrap().is_none());
5472 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
5473 assert_eq!(
5474 client
5475 .query("SELECT title FROM tasks WHERE id='queued'", &[])
5476 .unwrap()[0]["title"],
5477 "old"
5478 );
5479 assert_eq!(
5480 client
5481 .conn
5482 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5483 .get::<_, i64>(0))
5484 .unwrap(),
5485 1
5486 );
5487 assert!(client.drain_change_batches().is_empty());
5488 assert!(client.drain_sync_intents().is_empty());
5489 assert!(client.conn.is_autocommit());
5490 }
5491 }
5492
5493 #[test]
5494 fn cached_blob_cleanup_propagates_delete_failures() {
5495 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5496 {"name":"id","type":"string","nullable":false},
5497 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5498 let mut client =
5499 SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
5500 client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
5501 client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
5502 client
5503 .subscribe("attachments".into(), "attachments".into(), vec![], None)
5504 .unwrap();
5505 client.subs[0].effective = Some(vec![]);
5506 client.persist_sub(&client.subs[0]).unwrap();
5507 let revision = client.local_revision();
5508 let (_, meta) = client.build_request(false).unwrap();
5509 let mut transport = CountingRealtimeTransport::default();
5510 let mut report = SyncReport::default();
5511 assert!(client
5512 .process_section(
5513 &mut transport,
5514 "attachments",
5515 SubStatus::Revoked,
5516 "sync.scope_revoked",
5517 vec![],
5518 vec![],
5519 Some((0, None)),
5520 &meta,
5521 &mut report
5522 )
5523 .is_err());
5524 assert_eq!(client.subs[0].state, SubState::Active);
5525 assert_eq!(client.local_revision(), revision);
5526 assert!(report.revoked.is_empty());
5527 assert!(client.conn.is_autocommit());
5528 client
5529 .conn
5530 .execute_batch("DROP TRIGGER refuse_blob_cleanup")
5531 .unwrap();
5532 assert!(client
5533 .process_section(
5534 &mut transport,
5535 "attachments",
5536 SubStatus::Revoked,
5537 "sync.scope_revoked",
5538 vec![],
5539 vec![],
5540 Some((0, None)),
5541 &meta,
5542 &mut report
5543 )
5544 .is_ok());
5545 assert_eq!(client.subs[0].state, SubState::Revoked);
5546 assert_eq!(
5547 client
5548 .conn
5549 .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
5550 .get::<_, i64>(0))
5551 .unwrap(),
5552 0
5553 );
5554 }
5555
5556 #[test]
5557 fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
5558 let path =
5559 std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
5560 let schema = replay_schema(false);
5561 let mut client = SyncClient::open_path(
5562 "replay".into(),
5563 &schema,
5564 ClientLimits::default(),
5565 path.to_str().unwrap(),
5566 )
5567 .unwrap();
5568 let ids = ["early", "bad"].map(|id| {
5569 client
5570 .mutate(vec![Mutation::Upsert {
5571 table: "tasks".into(),
5572 values: Map::from_iter([
5573 ("id".into(), json!(id)),
5574 ("project_id".into(), json!("p1")),
5575 ("title".into(), json!(id)),
5576 ("done".into(), json!(false)),
5577 ]),
5578 base_version: None,
5579 }])
5580 .unwrap()
5581 });
5582 client.conn.execute_batch("DELETE FROM tasks; CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.id='bad' BEGIN SELECT RAISE(ABORT,'replay failed'); END;").unwrap();
5583 let revision = client.local_revision();
5584 drop(client);
5585 let error = SyncClient::open_path(
5586 "replay".into(),
5587 &schema,
5588 ClientLimits::default(),
5589 path.to_str().unwrap(),
5590 )
5591 .err()
5592 .expect("reopen must fail");
5593 assert!(error.contains("replay failed"));
5594 let conn = Connection::open(&path).unwrap();
5595 assert_eq!(
5596 conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
5597 .unwrap(),
5598 0
5599 );
5600 assert_eq!(
5601 conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5602 .get::<_, i64>(0))
5603 .unwrap(),
5604 2
5605 );
5606 assert_eq!(
5607 meta_get(&conn, LOCAL_REVISION_KEY),
5608 Some(revision.to_string())
5609 );
5610 conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
5611 drop(conn);
5612 let reopened = SyncClient::open_path(
5613 "replay".into(),
5614 &schema,
5615 ClientLimits::default(),
5616 path.to_str().unwrap(),
5617 )
5618 .unwrap();
5619 assert_eq!(reopened.pending_commit_ids(), ids);
5620 assert_eq!(
5621 reopened
5622 .query("SELECT id FROM tasks ORDER BY id", &[])
5623 .unwrap()
5624 .len(),
5625 2
5626 );
5627 drop(reopened);
5628 std::fs::remove_file(path).unwrap();
5629 }
5630
5631 #[test]
5632 fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
5633 let schema = replay_schema(false);
5634 let mut client =
5635 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5636 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5637 client
5638 .write_base_row(
5639 "tasks",
5640 &vec![
5641 Some(ColumnValue::String("queued".into())),
5642 Some(ColumnValue::String("p1".into())),
5643 Some(ColumnValue::String("server".into())),
5644 Some(ColumnValue::Boolean(false)),
5645 ],
5646 1,
5647 )
5648 .unwrap();
5649 client.rebuild_overlay().unwrap();
5650 let id = client
5651 .patch(
5652 "tasks",
5653 "queued",
5654 Map::from_iter([("title".into(), json!("local"))]),
5655 Some(1),
5656 )
5657 .unwrap();
5658 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
5659 let base = client
5660 .query("SELECT * FROM _syncular_base_tasks", &[])
5661 .unwrap();
5662 let revision = client.local_revision();
5663 client.drain_change_batches();
5664 client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
5665 let remote = vec![
5666 Some(ColumnValue::String("queued".into())),
5667 Some(ColumnValue::String("p1".into())),
5668 Some(ColumnValue::String("remote".into())),
5669 Some(ColumnValue::Boolean(false)),
5670 ];
5671 let bytes = crate::values::encode_row_json(
5672 client.schema.table("tasks").unwrap(),
5673 "queued",
5674 &Map::from_iter(
5675 client
5676 .schema
5677 .table("tasks")
5678 .unwrap()
5679 .columns
5680 .iter()
5681 .zip(&remote)
5682 .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
5683 ),
5684 &client.encryption,
5685 )
5686 .unwrap();
5687 let change = ssp2::model::Change {
5688 table_index: 0,
5689 op: Op::Upsert,
5690 row_id: "queued".into(),
5691 row_version: Some(2),
5692 scopes: vec![("project_id".into(), "p1".into())],
5693 row: Some(bytes),
5694 };
5695 assert!(client
5696 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
5697 .is_err());
5698 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5699 assert_eq!(
5700 client
5701 .query("SELECT * FROM _syncular_base_tasks", &[])
5702 .unwrap(),
5703 base
5704 );
5705 assert_eq!(client.local_revision(), revision);
5706 assert!(client.drain_change_batches().is_empty());
5707 let response = Message {
5708 wire_version: 2,
5709 msg_kind: MsgKind::Response,
5710 frames: vec![
5711 Frame::RespHeader {
5712 required_schema_version: None,
5713 latest_schema_version: None,
5714 log_epoch: Some("epoch-1".into()),
5715 reset_required: Some(false),
5716 },
5717 Frame::PushResult {
5718 client_commit_id: id.clone(),
5719 status: PushStatus::Applied,
5720 commit_seq: Some(2),
5721 results: vec![OpResult::Applied { op_index: 0 }],
5722 },
5723 ],
5724 };
5725 let (_, meta) = client.build_request(false).unwrap();
5726 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5727 assert!(
5728 matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
5729 );
5730 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5731 assert!(client.commit_outcome(&id).unwrap().is_none());
5732 assert_eq!(
5733 client
5734 .conn
5735 .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
5736 .get::<_, i64>(
5737 0
5738 ))
5739 .unwrap(),
5740 0
5741 );
5742 assert_eq!(
5743 client
5744 .conn
5745 .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
5746 row.get::<_, i64>(0)
5747 })
5748 .unwrap(),
5749 0
5750 );
5751 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5752 assert_eq!(client.local_revision(), revision);
5753 assert!(client.drain_change_batches().is_empty());
5754 client
5755 .conn
5756 .execute_batch("DROP TRIGGER fail_replay")
5757 .unwrap();
5758 assert!(matches!(
5759 client.process_response(&mut transport, response, &meta),
5760 SyncOutcome::Ok(_)
5761 ));
5762 client
5763 .apply_commit_frame(&["tasks".into()], &[change], Some(2))
5764 .unwrap();
5765 assert!(client.pending_commit_ids().is_empty());
5766 assert_eq!(
5767 client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
5768 json!("remote")
5769 );
5770 }
5771
5772 #[test]
5773 fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
5774 let client = SyncClient::new(
5775 "replay".into(),
5776 &replay_schema(false),
5777 ClientLimits::default(),
5778 )
5779 .unwrap();
5780 let table = client.schema.table("tasks").unwrap();
5781 let patch = OutboxOp {
5782 table: "tasks".into(),
5783 row_id: "absent".into(),
5784 upsert: true,
5785 base_version: Some(1),
5786 values: Some(Map::from_iter([
5787 ("id".into(), json!("absent")),
5788 ("title".into(), json!("patch")),
5789 ])),
5790 };
5791 assert!(client.visible_row(table, "absent").unwrap().is_none());
5792 client
5793 .apply_outbox_ops([&patch])
5794 .expect("sparse replay over truly absent row");
5795 client
5796 .conn
5797 .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
5798 .unwrap();
5799 assert!(client
5800 .visible_row(table, "bad")
5801 .unwrap_err()
5802 .starts_with("sync.local_corrupt:"));
5803 let mut invalid = patch.clone();
5804 invalid.values = Some(Map::from_iter([
5805 ("id".into(), json!("bad-value")),
5806 ("project_id".into(), json!("p1")),
5807 ("title".into(), json!("title")),
5808 ("done".into(), json!({"bad":true})),
5809 ]));
5810 assert!(client
5811 .apply_outbox_ops([&invalid])
5812 .unwrap_err()
5813 .starts_with("sync.local_corrupt:"));
5814 client.conn.execute_batch("DROP TABLE tasks").unwrap();
5815 assert!(
5816 client.apply_outbox_ops([&patch]).is_err(),
5817 "SQL failure must not count as absence"
5818 );
5819 }
5820
5821 #[test]
5822 fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
5823 let mut schema = replay_schema(false);
5824 schema["tables"][0]["indexes"] =
5825 json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
5826 let mut client =
5827 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5828 let id = client
5829 .mutate(vec![Mutation::Upsert {
5830 table: "tasks".into(),
5831 values: Map::from_iter([
5832 ("id".into(), json!("queued")),
5833 ("project_id".into(), json!("p1")),
5834 ("title".into(), json!("occupied")),
5835 ("done".into(), json!(false)),
5836 ]),
5837 base_version: None,
5838 }])
5839 .unwrap();
5840 client
5841 .write_base_row(
5842 "tasks",
5843 &vec![
5844 Some(ColumnValue::String("server".into())),
5845 Some(ColumnValue::String("p1".into())),
5846 Some(ColumnValue::String("occupied".into())),
5847 Some(ColumnValue::Boolean(false)),
5848 ],
5849 1,
5850 )
5851 .unwrap();
5852 client.rebuild_overlay_if_dirty().unwrap();
5853 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5854 assert!(client
5855 .visible_row(client.schema.table("tasks").unwrap(), "queued")
5856 .unwrap()
5857 .is_none());
5858 assert!(client.rejections().is_empty());
5859 client.delete_base_row("tasks", "server").unwrap();
5860 client.rebuild_overlay_if_dirty().unwrap();
5861 assert!(client
5862 .visible_row(client.schema.table("tasks").unwrap(), "queued")
5863 .unwrap()
5864 .is_some());
5865 client
5866 .write_base_row(
5867 "tasks",
5868 &vec![
5869 Some(ColumnValue::String("base".into())),
5870 Some(ColumnValue::String("p1".into())),
5871 Some(ColumnValue::String("base-title".into())),
5872 Some(ColumnValue::Boolean(false)),
5873 ],
5874 1,
5875 )
5876 .unwrap();
5877 client.conn.execute_batch("DROP TABLE tasks").unwrap();
5878 client
5879 .retain_failed_commit(
5880 "retained",
5881 &[OutboxOp {
5882 table: "tasks".into(),
5883 row_id: "base".into(),
5884 upsert: true,
5885 base_version: Some(1),
5886 values: Some(Map::from_iter([
5887 ("id".into(), json!("base")),
5888 ("title".into(), json!("patch")),
5889 ])),
5890 }],
5891 )
5892 .expect("an existing base must not read the unavailable visible table");
5893 }
5894
5895 #[test]
5896 fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
5897 for failure in ["fts", "release"] {
5898 let mut client = SyncClient::new(
5899 "replay".into(),
5900 &replay_schema(true),
5901 ClientLimits::default(),
5902 )
5903 .unwrap();
5904 client
5905 .mutate(vec![Mutation::Upsert {
5906 table: "tasks".into(),
5907 values: Map::from_iter([
5908 ("id".into(), json!("queued")),
5909 ("project_id".into(), json!("p1")),
5910 ("title".into(), json!("needle")),
5911 ("done".into(), json!(false)),
5912 ]),
5913 base_version: None,
5914 }])
5915 .unwrap();
5916 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5917 let fts_before = client
5918 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
5919 .unwrap();
5920 let triggers_before: Vec<String> = client
5921 .conn
5922 .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
5923 .unwrap()
5924 .query_map([], |row| row.get(0))
5925 .unwrap()
5926 .collect::<Result<_, _>>()
5927 .unwrap();
5928 if failure == "fts" {
5929 client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
5930 } else {
5931 client.conn.execute_batch("PRAGMA foreign_keys=ON; CREATE TABLE parent(id TEXT PRIMARY KEY); CREATE TABLE deferred(id TEXT REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_release AFTER INSERT ON tasks BEGIN INSERT INTO deferred VALUES ('missing'); END;").unwrap();
5932 }
5933 client.overlay_dirty.set(true);
5934 assert!(client.rebuild_overlay().is_err());
5935 assert!(client.overlay_dirty.get());
5936 assert!(client.conn.is_autocommit());
5937 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5938 assert_eq!(
5939 client
5940 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
5941 .unwrap(),
5942 fts_before
5943 );
5944 let original_triggers: Vec<String> = client.conn.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name NOT LIKE 'fail_%' ORDER BY name").unwrap().query_map([], |row| row.get(0)).unwrap().collect::<Result<_,_>>().unwrap();
5945 assert_eq!(original_triggers, triggers_before);
5946 assert_eq!(client.pending_commit_ids().len(), 1);
5947 client
5948 .conn
5949 .execute_batch(if failure == "fts" {
5950 "DROP TRIGGER fail_fts"
5951 } else {
5952 "DROP TRIGGER fail_release"
5953 })
5954 .unwrap();
5955 client
5956 .rebuild_overlay()
5957 .expect("retry after removing the fault");
5958 }
5959 }
5960
5961 #[test]
5962 fn secondary_unique_collision_preserves_existing_synced_row() {
5963 let client = SyncClient::new(
5964 "unique-upsert-test".to_owned(),
5965 &json!({
5966 "version": 1,
5967 "tables": [{
5968 "name": "tasks",
5969 "primaryKey": "id",
5970 "columns": [
5971 { "name": "id", "type": "string", "nullable": false },
5972 { "name": "project_id", "type": "string", "nullable": false },
5973 { "name": "title", "type": "string", "nullable": false }
5974 ],
5975 "scopes": [{ "pattern": "project:{project_id}" }],
5976 "indexes": [{
5977 "name": "tasks_by_project_title",
5978 "columns": ["project_id", "title"],
5979 "unique": true
5980 }]
5981 }]
5982 }),
5983 ClientLimits::default(),
5984 )
5985 .expect("test client");
5986 let table = client.schema.table("tasks").expect("tasks table");
5987 let sql = client.insert_row_sql(&base_table("tasks"), table);
5988
5989 client
5990 .conn
5991 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
5992 .expect("insert first row");
5993 client
5994 .conn
5995 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
5996 .expect("update same primary key");
5997 client
5998 .conn
5999 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
6000 .expect("insert second row");
6001 assert!(client
6002 .conn
6003 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
6004 .is_err());
6005
6006 let rows = client
6007 .conn
6008 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
6009 .expect("prepare rows")
6010 .query_map([], |row| {
6011 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6012 })
6013 .expect("query rows")
6014 .collect::<Result<Vec<_>, _>>()
6015 .expect("collect rows");
6016 assert_eq!(
6017 rows,
6018 vec![
6019 ("t1".to_owned(), "updated".to_owned()),
6020 ("t2".to_owned(), "original".to_owned())
6021 ]
6022 );
6023 }
6024
6025 #[test]
6026 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
6027 let path = std::env::temp_dir().join(format!(
6028 "syncular-startup-intent-{}.db",
6029 uuid::Uuid::new_v4()
6030 ));
6031 let schema = json!({
6032 "version": 1,
6033 "tables": [{
6034 "name": "tasks",
6035 "primaryKey": "id",
6036 "columns": [
6037 { "name": "id", "type": "string", "nullable": false },
6038 { "name": "project_id", "type": "string", "nullable": false }
6039 ],
6040 "scopes": [{ "pattern": "project:{project_id}" }]
6041 }]
6042 });
6043
6044 {
6045 let mut first = SyncClient::open_path_with_identity(
6046 None,
6047 &schema,
6048 ClientLimits::default(),
6049 path.to_str().expect("UTF-8 temp path"),
6050 )
6051 .expect("first open");
6052 first
6053 .set_window(
6054 &WindowBase {
6055 table: "tasks".to_owned(),
6056 variable: "project_id".to_owned(),
6057 fixed_scopes: Vec::new(),
6058 params: None,
6059 },
6060 &["persisted".to_owned()],
6061 )
6062 .expect("persist window");
6063 }
6064
6065 let mut reopened = SyncClient::open_path_with_identity(
6066 None,
6067 &schema,
6068 ClientLimits::default(),
6069 path.to_str().expect("UTF-8 temp path"),
6070 )
6071 .expect("reopen");
6072 assert!(reopened.sync_needed());
6073 assert!(matches!(
6074 reopened.drain_sync_intents().as_slice(),
6075 [SyncIntent::Interactive]
6076 ));
6077 drop(reopened);
6078 std::fs::remove_file(path).expect("remove temp database");
6079 }
6080
6081 #[test]
6082 fn reopening_preserves_immutable_subscription_identity_and_progress() {
6083 let path = std::env::temp_dir().join(format!(
6084 "syncular-subscription-identity-{}.db",
6085 uuid::Uuid::new_v4()
6086 ));
6087 let schema = json!({
6088 "version": 1,
6089 "tables": [
6090 {
6091 "name": "tasks",
6092 "primaryKey": "id",
6093 "columns": [
6094 { "name": "id", "type": "string", "nullable": false },
6095 { "name": "project_id", "type": "string", "nullable": false }
6096 ],
6097 "scopes": [{ "pattern": "project:{project_id}" }]
6098 },
6099 {
6100 "name": "docs",
6101 "primaryKey": "id",
6102 "columns": [
6103 { "name": "id", "type": "string", "nullable": false },
6104 { "name": "org_id", "type": "string", "nullable": false },
6105 { "name": "project_id", "type": "string", "nullable": false }
6106 ],
6107 "scopes": [
6108 { "pattern": "org:{org_id}" },
6109 { "pattern": "project:{projectId}", "column": "project_id" }
6110 ]
6111 }
6112 ]
6113 });
6114
6115 {
6116 let mut first = SyncClient::open_path_with_identity(
6117 None,
6118 &schema,
6119 ClientLimits::default(),
6120 path.to_str().expect("UTF-8 temp path"),
6121 )
6122 .expect("first open");
6123 first
6124 .subscribe(
6125 "stable-subscription".to_owned(),
6126 "tasks".to_owned(),
6127 vec![(
6128 "project_id".to_owned(),
6129 vec!["p2".to_owned(), "p1".to_owned()],
6130 )],
6131 Some(r#"{"view":"v1"}"#.to_owned()),
6132 )
6133 .expect("persist subscription");
6134 let persisted = {
6135 let subscription = first
6136 .subs
6137 .iter_mut()
6138 .find(|subscription| subscription.id == "stable-subscription")
6139 .expect("subscription");
6140 subscription.cursor = 41;
6141 subscription.bootstrap_state = Some("resume-token".to_owned());
6142 subscription.effective = Some(vec![(
6143 "project_id".to_owned(),
6144 vec!["p1".to_owned(), "p2".to_owned()],
6145 )]);
6146 subscription.synced_once = true;
6147 subscription.clone()
6148 };
6149 first.persist_sub(&persisted).unwrap();
6150 }
6151
6152 let mut reopened = SyncClient::open_path_with_identity(
6153 None,
6154 &schema,
6155 ClientLimits::default(),
6156 path.to_str().expect("UTF-8 temp path"),
6157 )
6158 .expect("reopen");
6159 let progress = reopened
6160 .subscription_state("stable-subscription")
6161 .expect("persisted state");
6162 assert_eq!(progress.cursor, 41);
6163 assert!(progress.has_resume_token);
6164
6165 reopened
6166 .subscribe(
6167 "stable-subscription".to_owned(),
6168 "tasks".to_owned(),
6169 vec![(
6170 "project_id".to_owned(),
6171 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
6172 )],
6173 Some(r#"{"view":"v1"}"#.to_owned()),
6174 )
6175 .expect("canonical intent is idempotent");
6176 assert_eq!(
6177 reopened
6178 .subscription_state("stable-subscription")
6179 .expect("unchanged state")
6180 .cursor,
6181 progress.cursor
6182 );
6183
6184 for (table, scopes, params) in [
6185 (
6186 "tasks",
6187 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6188 Some(r#"{"view":"v1"}"#.to_owned()),
6189 ),
6190 (
6191 "tasks",
6192 vec![(
6193 "project_id".to_owned(),
6194 vec!["p2".to_owned(), "p1".to_owned()],
6195 )],
6196 Some(r#"{"view":"v2"}"#.to_owned()),
6197 ),
6198 (
6199 "docs",
6200 vec![
6201 ("org_id".to_owned(), vec!["o1".to_owned()]),
6202 ("projectId".to_owned(), vec!["p1".to_owned()]),
6203 ],
6204 Some(r#"{"view":"v1"}"#.to_owned()),
6205 ),
6206 ] {
6207 let error = reopened
6208 .subscribe(
6209 "stable-subscription".to_owned(),
6210 table.to_owned(),
6211 scopes,
6212 params,
6213 )
6214 .expect_err("identity rebind must fail");
6215 assert!(error.starts_with("client.subscription_intent_mismatch:"));
6216 assert_eq!(
6217 reopened
6218 .subscription_state("stable-subscription")
6219 .expect("unchanged state")
6220 .cursor,
6221 progress.cursor
6222 );
6223 }
6224
6225 drop(reopened);
6226 std::fs::remove_file(path).expect("remove temp database");
6227 }
6228
6229 #[test]
6230 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
6231 let path = std::env::temp_dir().join(format!(
6232 "syncular-satisfied-schema-floor-{}.db",
6233 uuid::Uuid::new_v4()
6234 ));
6235 let schema = json!({
6236 "version": 23,
6237 "tables": [{
6238 "name": "tasks",
6239 "primaryKey": "id",
6240 "columns": [
6241 { "name": "id", "type": "string", "nullable": false },
6242 { "name": "project_id", "type": "string", "nullable": false }
6243 ],
6244 "scopes": [{ "pattern": "project:{project_id}" }]
6245 }]
6246 });
6247
6248 {
6249 let mut first = SyncClient::open_path_with_identity(
6250 None,
6251 &schema,
6252 ClientLimits::default(),
6253 path.to_str().expect("UTF-8 temp path"),
6254 )
6255 .expect("first open");
6256 first
6257 .subscribe(
6258 "tasks".to_owned(),
6259 "tasks".to_owned(),
6260 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6261 None,
6262 )
6263 .expect("persist subscription");
6264 first.set_schema_floor(Some(SchemaFloor {
6265 required_schema_version: Some(22),
6266 latest_schema_version: Some(22),
6267 }));
6268 }
6269
6270 let mut reopened = SyncClient::open_path_with_identity(
6271 None,
6272 &schema,
6273 ClientLimits::default(),
6274 path.to_str().expect("UTF-8 temp path"),
6275 )
6276 .expect("reopen");
6277 assert!(reopened.schema_floor().is_none());
6278 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
6279 assert!(reopened.sync_needed());
6280 assert!(matches!(
6281 reopened.drain_sync_intents().as_slice(),
6282 [SyncIntent::Interactive]
6283 ));
6284 drop(reopened);
6285 std::fs::remove_file(path).expect("remove temp database");
6286 }
6287
6288 #[test]
6289 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
6290 let path = std::env::temp_dir().join(format!(
6291 "syncular-unsatisfied-schema-floor-{}.db",
6292 uuid::Uuid::new_v4()
6293 ));
6294 let schema = json!({
6295 "version": 1,
6296 "tables": [{
6297 "name": "tasks",
6298 "primaryKey": "id",
6299 "columns": [
6300 { "name": "id", "type": "string", "nullable": false },
6301 { "name": "project_id", "type": "string", "nullable": false }
6302 ],
6303 "scopes": [{ "pattern": "project:{project_id}" }]
6304 }]
6305 });
6306
6307 {
6308 let mut first = SyncClient::open_path_with_identity(
6309 None,
6310 &schema,
6311 ClientLimits::default(),
6312 path.to_str().expect("UTF-8 temp path"),
6313 )
6314 .expect("first open");
6315 first
6316 .subscribe(
6317 "tasks".to_owned(),
6318 "tasks".to_owned(),
6319 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6320 None,
6321 )
6322 .expect("persist subscription");
6323 first.set_schema_floor(Some(SchemaFloor {
6324 required_schema_version: Some(2),
6325 latest_schema_version: Some(2),
6326 }));
6327 }
6328
6329 let reopened = SyncClient::open_path_with_identity(
6330 None,
6331 &schema,
6332 ClientLimits::default(),
6333 path.to_str().expect("UTF-8 temp path"),
6334 )
6335 .expect("reopen");
6336 assert_eq!(
6337 reopened.schema_floor(),
6338 Some(&SchemaFloor {
6339 required_schema_version: Some(2),
6340 latest_schema_version: Some(2),
6341 })
6342 );
6343 assert!(!reopened.sync_needed());
6344 drop(reopened);
6345 std::fs::remove_file(path).expect("remove temp database");
6346 }
6347
6348 #[test]
6349 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
6350 let path = std::env::temp_dir().join(format!(
6351 "syncular-indexed-column-bump-{}.db",
6352 uuid::Uuid::new_v4()
6353 ));
6354 let old_schema = json!({
6355 "version": 1,
6356 "tables": [
6357 {
6358 "name": "tasks",
6359 "primaryKey": "id",
6360 "columns": [
6361 { "name": "id", "type": "string", "nullable": false },
6362 { "name": "project_id", "type": "string", "nullable": false }
6363 ],
6364 "scopes": [{ "pattern": "project:{project_id}" }]
6365 },
6366 {
6367 "name": "legacy",
6368 "primaryKey": "id",
6369 "columns": [
6370 { "name": "id", "type": "string", "nullable": false },
6371 { "name": "project_id", "type": "string", "nullable": false }
6372 ],
6373 "scopes": [{ "pattern": "project:{project_id}" }]
6374 }
6375 ]
6376 });
6377 {
6378 let mut first = SyncClient::open_path_with_identity(
6379 None,
6380 &old_schema,
6381 ClientLimits::default(),
6382 path.to_str().expect("UTF-8 temp path"),
6383 )
6384 .expect("open old schema");
6385 first
6386 .subscribe(
6387 "legacy-sub".to_owned(),
6388 "legacy".to_owned(),
6389 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6390 None,
6391 )
6392 .expect("persist legacy subscription");
6393 }
6394
6395 let new_schema = json!({
6396 "version": 2,
6397 "tables": [{
6398 "name": "tasks",
6399 "primaryKey": "id",
6400 "columns": [
6401 { "name": "id", "type": "string", "nullable": false },
6402 { "name": "project_id", "type": "string", "nullable": false },
6403 { "name": "facility_membership_id", "type": "string", "nullable": true }
6404 ],
6405 "scopes": [{ "pattern": "project:{project_id}" }],
6406 "indexes": [{
6407 "name": "tasks_by_membership",
6408 "columns": ["project_id", "facility_membership_id"],
6409 "unique": false
6410 }]
6411 }]
6412 });
6413 let upgraded = SyncClient::open_path_with_identity(
6414 None,
6415 &new_schema,
6416 ClientLimits::default(),
6417 path.to_str().expect("UTF-8 temp path"),
6418 )
6419 .expect("open upgraded schema");
6420 let columns = upgraded
6421 .conn
6422 .prepare("PRAGMA table_info(tasks)")
6423 .expect("prepare columns")
6424 .query_map([], |row| row.get::<_, String>(1))
6425 .expect("query columns")
6426 .collect::<Result<Vec<_>, _>>()
6427 .expect("collect columns");
6428 assert!(columns.contains(&"facility_membership_id".to_owned()));
6429 let index_count: i64 = upgraded
6430 .conn
6431 .query_row(
6432 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
6433 [],
6434 |row| row.get(0),
6435 )
6436 .expect("query index");
6437 assert_eq!(index_count, 1);
6438 assert!(upgraded.subscription_state("legacy-sub").is_none());
6439 drop(upgraded);
6440 std::fs::remove_file(path).expect("remove temp database");
6441 }
6442
6443 #[test]
6444 fn migrates_pre_envelope_outcome_journal_additively() {
6445 let path = std::env::temp_dir().join(format!(
6446 "syncular-outcome-migration-{}.db",
6447 uuid::Uuid::new_v4()
6448 ));
6449 let conn = Connection::open(&path).expect("open legacy database");
6450 conn.execute_batch(
6451 "CREATE TABLE _syncular_commit_outcomes (
6452 seq INTEGER PRIMARY KEY AUTOINCREMENT,
6453 client_commit_id TEXT NOT NULL UNIQUE,
6454 status TEXT NOT NULL,
6455 recorded_at_ms INTEGER NOT NULL,
6456 results_json TEXT NOT NULL,
6457 resolution TEXT NOT NULL DEFAULT 'active',
6458 resolved_at_ms INTEGER,
6459 replacement_client_commit_id TEXT);",
6460 )
6461 .expect("create legacy outcome journal");
6462 drop(conn);
6463 let schema = json!({
6464 "version": 1,
6465 "tables": [{
6466 "name": "tasks",
6467 "primaryKey": "id",
6468 "columns": [
6469 { "name": "id", "type": "string", "nullable": false },
6470 { "name": "project_id", "type": "string", "nullable": false }
6471 ],
6472 "scopes": [{ "pattern": "project:{project_id}" }]
6473 }]
6474 });
6475 let client = SyncClient::open_path(
6476 "migration-native".to_owned(),
6477 &schema,
6478 ClientLimits::default(),
6479 path.to_str().expect("UTF-8 temp path"),
6480 )
6481 .expect("migrate database");
6482 let has_operations = client
6483 .conn
6484 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
6485 .expect("prepare table info")
6486 .query_map([], |row| row.get::<_, String>(1))
6487 .expect("query table info")
6488 .filter_map(Result::ok)
6489 .any(|column| column == "operations_json");
6490 assert!(has_operations);
6491 drop(client);
6492 std::fs::remove_file(path).expect("remove temp database");
6493 }
6494
6495 #[test]
6496 fn retained_rejection_survives_file_reopen_and_security_purge() {
6497 let path =
6498 std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
6499 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
6500 { "name": "id", "type": "string", "nullable": false },
6501 { "name": "project_id", "type": "string", "nullable": false },
6502 { "name": "title", "type": "string", "nullable": false }
6503 ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
6504 {
6505 let mut first = SyncClient::open_path(
6506 "retained-native".to_owned(),
6507 &schema,
6508 ClientLimits::default(),
6509 path.to_str().unwrap(),
6510 )
6511 .unwrap();
6512 first
6513 .write_base_row(
6514 "tasks",
6515 &vec![
6516 Some(ColumnValue::String("t1".into())),
6517 Some(ColumnValue::String("p1".into())),
6518 Some(ColumnValue::String("server".into())),
6519 ],
6520 1,
6521 )
6522 .unwrap();
6523 first.rebuild_overlay().expect("rebuild overlay");
6524 let operation = OutboxOp {
6525 table: "tasks".into(),
6526 row_id: "t1".into(),
6527 upsert: true,
6528 base_version: Some(1),
6529 values: Some(Map::from_iter([
6530 ("id".into(), json!("t1")),
6531 ("title".into(), json!("mine")),
6532 ])),
6533 };
6534 first
6535 .retain_failed_commit("retained", std::slice::from_ref(&operation))
6536 .unwrap();
6537 first
6538 .persist_commit_outcome(
6539 "retained",
6540 CommitOutcomeStatus::Rejected,
6541 &[CommitOperationOutcome::Error {
6542 rejection: RejectionRecord {
6543 client_commit_id: "retained".into(),
6544 op_index: 0,
6545 code: "validation.denied".into(),
6546 message: "denied".into(),
6547 retryable: false,
6548 details: None,
6549 operation: None,
6550 },
6551 }],
6552 Some(&[operation]),
6553 )
6554 .unwrap();
6555 first.rebuild_overlay().expect("rebuild overlay");
6556 }
6557 {
6558 let mut reopened = SyncClient::open_path(
6559 "retained-native".to_owned(),
6560 &schema,
6561 ClientLimits::default(),
6562 path.to_str().unwrap(),
6563 )
6564 .unwrap();
6565 let row = reopened
6566 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6567 .expect("read visible row")
6568 .unwrap();
6569 assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
6570 assert_eq!(
6571 reopened
6572 .commit_outcome("retained")
6573 .unwrap()
6574 .unwrap()
6575 .retained_rows
6576 .unwrap()[0]
6577 .server_row
6578 .as_ref()
6579 .unwrap()["title"],
6580 json!("server")
6581 );
6582 reopened
6583 .conn
6584 .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
6585 .unwrap();
6586 reopened.rebuild_overlay().expect("rebuild overlay");
6587 assert!(reopened
6588 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6589 .expect("read visible row")
6590 .is_none());
6591 let retained = reopened.commit_outcome("retained").unwrap().unwrap();
6592 assert!(retained.retained_rows.is_some());
6593 assert_eq!(
6594 retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
6595 json!("mine")
6596 );
6597 reopened
6598 .purge_local_data(&LocalDataPurgeInput {
6599 purge_id: "protected-purge".into(),
6600 targets: vec![LocalDataPurgeTarget {
6601 table: "tasks".into(),
6602 selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
6603 }],
6604 })
6605 .unwrap();
6606 assert!(reopened
6607 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6608 .expect("read visible row")
6609 .is_none());
6610 assert!(reopened
6611 .commit_outcome("retained")
6612 .unwrap()
6613 .unwrap()
6614 .retained_rows
6615 .is_none());
6616 }
6617 std::fs::remove_file(path).unwrap();
6618 }
6619
6620 #[test]
6621 fn durable_conflict_outcome_and_resolution_survive_reopen() {
6622 let path = std::env::temp_dir().join(format!(
6623 "syncular-durable-outcome-{}.db",
6624 uuid::Uuid::new_v4()
6625 ));
6626 let schema = json!({
6627 "version": 1,
6628 "tables": [{
6629 "name": "tasks",
6630 "primaryKey": "id",
6631 "columns": [
6632 { "name": "id", "type": "string", "nullable": false },
6633 { "name": "project_id", "type": "string", "nullable": false }
6634 ],
6635 "scopes": [{ "pattern": "project:{project_id}" }]
6636 }]
6637 });
6638 let conflict = ConflictRecord {
6639 client_commit_id: "losing-commit".to_owned(),
6640 op_index: 0,
6641 table: "tasks".to_owned(),
6642 row_id: "t1".to_owned(),
6643 code: "sync.version_conflict".to_owned(),
6644 message: "stale base version".to_owned(),
6645 server_version: 2,
6646 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6647 conflict_columns: vec!["project_id".to_owned()],
6648 operation: Some(CommitOperation {
6649 table: "tasks".to_owned(),
6650 row_id: "t1".to_owned(),
6651 op: "upsert".to_owned(),
6652 base_version: Some(1),
6653 values: Some(Map::from_iter([
6654 ("id".into(), json!("t1")),
6655 ("project_id".into(), json!("p1")),
6656 ])),
6657 }),
6658 };
6659 let failed_operations = vec![
6660 OutboxOp {
6661 upsert: true,
6662 table: "tasks".to_owned(),
6663 row_id: "t1".to_owned(),
6664 base_version: Some(1),
6665 values: Some(Map::from_iter([
6666 ("id".into(), json!("t1")),
6667 ("project_id".into(), json!("p1")),
6668 ])),
6669 },
6670 OutboxOp {
6671 upsert: true,
6672 table: "tasks".to_owned(),
6673 row_id: "status-event-1".to_owned(),
6674 base_version: Some(0),
6675 values: Some(Map::from_iter([
6676 ("id".into(), json!("status-event-1")),
6677 ("project_id".into(), json!("p1")),
6678 ])),
6679 },
6680 ];
6681
6682 {
6683 let mut first = SyncClient::open_path(
6684 "durable-native".to_owned(),
6685 &schema,
6686 ClientLimits::default(),
6687 path.to_str().expect("UTF-8 temp path"),
6688 )
6689 .expect("first open");
6690 first
6691 .begin_observation("test_outcome")
6692 .expect("begin outcome");
6693 first
6694 .persist_commit_outcome(
6695 "losing-commit",
6696 CommitOutcomeStatus::Conflict,
6697 &[CommitOperationOutcome::Conflict {
6698 conflict: conflict.clone(),
6699 }],
6700 Some(&failed_operations),
6701 )
6702 .expect("persist outcome");
6703 first.conflicts.push(conflict);
6704 first
6705 .finish_observation(
6706 "test_outcome",
6707 ChangeAccumulator {
6708 conflicts: true,
6709 outcomes: true,
6710 ..ChangeAccumulator::default()
6711 },
6712 )
6713 .expect("commit outcome");
6714 }
6715
6716 {
6717 let mut reopened = SyncClient::open_path(
6718 "durable-native".to_owned(),
6719 &schema,
6720 ClientLimits::default(),
6721 path.to_str().expect("UTF-8 temp path"),
6722 )
6723 .expect("reopen");
6724 assert_eq!(reopened.conflicts().len(), 1);
6725 assert_eq!(
6726 reopened.conflicts()[0].conflict_columns,
6727 vec!["project_id".to_owned()],
6728 "conflictColumns survives the journal round trip"
6729 );
6730 let outcome = reopened
6731 .commit_outcome("losing-commit")
6732 .expect("read outcome")
6733 .expect("outcome");
6734 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
6735 let operations = outcome.operations.expect("aggregate envelope");
6736 assert_eq!(operations.len(), 2);
6737 assert_eq!(operations[1].row_id, "status-event-1");
6738 let resolved = reopened
6739 .resolve_commit_outcome(ResolveCommitOutcomeInput {
6740 client_commit_id: "losing-commit".to_owned(),
6741 resolution: CommitOutcomeResolution::ResolvedKeepServer,
6742 replacement_client_commit_id: None,
6743 })
6744 .expect("resolve");
6745 assert_eq!(
6746 resolved.resolution,
6747 CommitOutcomeResolution::ResolvedKeepServer
6748 );
6749 assert!(reopened.conflicts().is_empty());
6750 }
6751
6752 let reopened = SyncClient::open_path(
6753 "durable-native".to_owned(),
6754 &schema,
6755 ClientLimits::default(),
6756 path.to_str().expect("UTF-8 temp path"),
6757 )
6758 .expect("second reopen");
6759 assert!(reopened.conflicts().is_empty());
6760 assert_eq!(
6761 reopened
6762 .commit_outcome("losing-commit")
6763 .expect("read outcome")
6764 .expect("outcome")
6765 .resolution,
6766 CommitOutcomeResolution::ResolvedKeepServer
6767 );
6768 drop(reopened);
6769 std::fs::remove_file(path).expect("remove temp database");
6770 }
6771
6772 #[test]
6773 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
6774 let path =
6775 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6776 let schema = json!({
6777 "version": 1,
6778 "tables": [{
6779 "name": "tasks",
6780 "primaryKey": "id",
6781 "columns": [
6782 { "name": "id", "type": "string", "nullable": false },
6783 { "name": "project_id", "type": "string", "nullable": false }
6784 ],
6785 "scopes": [{ "pattern": "project:{project_id}" }]
6786 }]
6787 });
6788 let mut client = SyncClient::open_path_with_identity(
6789 Some("sidecar-client".to_owned()),
6790 &schema,
6791 ClientLimits::default(),
6792 path.to_str().expect("UTF-8 temp path"),
6793 )
6794 .expect("open owner");
6795 let base = WindowBase {
6796 table: "tasks".to_owned(),
6797 variable: "project_id".to_owned(),
6798 fixed_scopes: Vec::new(),
6799 params: None,
6800 };
6801 client
6802 .set_window(&base, &["one".to_owned()])
6803 .expect("set window");
6804 client
6805 .mutate(vec![Mutation::Upsert {
6806 table: "tasks".to_owned(),
6807 values: Map::from_iter([
6808 ("id".to_owned(), Value::from("t1")),
6809 ("project_id".to_owned(), Value::from("one")),
6810 ]),
6811 base_version: None,
6812 }])
6813 .expect("local mutate");
6814
6815 let coverage = [WindowCoverage {
6816 base,
6817 units: vec!["one".to_owned(), "missing".to_owned()],
6818 }];
6819 let owner = client
6820 .query_snapshot(
6821 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6822 &[],
6823 &coverage,
6824 None,
6825 )
6826 .expect("owner snapshot");
6827 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6828 let sidecar = reader
6829 .query_snapshot(
6830 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6831 &[],
6832 &coverage,
6833 )
6834 .expect("sidecar snapshot");
6835
6836 assert_eq!(sidecar.revision, owner.revision);
6837 assert_eq!(sidecar.rows, owner.rows);
6838 assert_eq!(
6839 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
6840 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
6841 );
6842 assert_eq!(sidecar.revision, "2");
6843 assert_eq!(sidecar.rows[0]["id"], "t1");
6844 assert_eq!(sidecar.rows[0]["server_version"], -1);
6845 assert!(!sidecar.coverage.complete);
6846 assert_eq!(sidecar.coverage.pending.len(), 1);
6847 assert_eq!(sidecar.coverage.missing.len(), 1);
6848
6849 drop(reader);
6850 drop(client);
6851 std::fs::remove_file(path).expect("remove temp database");
6852 }
6853
6854 #[test]
6855 fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
6856 let path =
6857 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6858 let schema = json!({
6859 "version": 1,
6860 "tables": [{
6861 "name": "tasks",
6862 "primaryKey": "id",
6863 "columns": [
6864 { "name": "id", "type": "string", "nullable": false },
6865 { "name": "project_id", "type": "string", "nullable": false }
6866 ],
6867 "scopes": [{ "pattern": "project:{project_id}" }]
6868 }]
6869 });
6870 let mut client = SyncClient::open_path_with_identity(
6871 Some("sidecar-catchup".to_owned()),
6872 &schema,
6873 ClientLimits::default(),
6874 path.to_str().expect("UTF-8 temp path"),
6875 )
6876 .expect("open owner");
6877 client
6878 .subscribe(
6879 "s1".into(),
6880 "tasks".into(),
6881 vec![("project_id".into(), vec!["one".into()])],
6882 None,
6883 )
6884 .expect("subscribe");
6885 let commit_id = client
6886 .mutate(vec![Mutation::Upsert {
6887 table: "tasks".to_owned(),
6888 values: Map::from_iter([
6889 ("id".to_owned(), Value::from("t1")),
6890 ("project_id".to_owned(), Value::from("one")),
6891 ]),
6892 base_version: None,
6893 }])
6894 .expect("mutate");
6895 let revision = client.local_revision().to_string();
6896
6897 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6898 assert_eq!(reader.local_revision().expect("revision"), revision);
6899
6900 let read = reader
6901 .snapshot_read(&SnapshotReadRequest {
6902 statements: vec![
6903 SnapshotStatement {
6904 sql: "SELECT 1 AS n",
6905 params: &[],
6906 },
6907 SnapshotStatement {
6908 sql: "SELECT id FROM tasks ORDER BY id",
6909 params: &[],
6910 },
6911 ],
6912 coverage: &[],
6913 subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
6914 commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
6915 })
6916 .expect("snapshot read");
6917 assert_eq!(read.revision, revision);
6918 assert_eq!(read.queries.len(), 2);
6919 assert_eq!(read.queries[0][0]["n"], 1);
6920 assert_eq!(read.queries[1][0]["id"], "t1");
6921 match &read.subscriptions[0] {
6922 SubscriptionCatchup::Known {
6923 status,
6924 cursor,
6925 bootstrap_complete,
6926 known_pending_pages,
6927 ..
6928 } => {
6929 assert_eq!(status, "active");
6930 assert_eq!(*cursor, -1);
6931 assert!(!bootstrap_complete);
6932 assert!(known_pending_pages);
6933 }
6934 other => panic!("expected a known subscription: {other:?}"),
6935 }
6936 assert!(matches!(
6937 &read.subscriptions[1],
6938 SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
6939 ));
6940 assert!(matches!(
6941 &read.deliveries[0],
6942 CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
6943 ));
6944 assert!(matches!(
6945 &read.deliveries[1],
6946 CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
6947 ));
6948
6949 assert!(reader
6951 .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
6952 .is_err());
6953 assert_eq!(
6954 reader.local_revision().expect("revision after rollback"),
6955 revision
6956 );
6957 assert!(reader
6959 .query_snapshot("DELETE FROM tasks", &[], &[])
6960 .is_err());
6961
6962 client
6964 .conn
6965 .execute(
6966 "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
6967 [],
6968 )
6969 .expect("corrupt revision");
6970 let failure = reader.local_revision().expect_err("corrupt revision fails");
6971 assert_eq!(failure.code, Some("sync.local_corrupt"));
6972
6973 drop(reader);
6974 drop(client);
6975 std::fs::remove_file(path).expect("remove temp database");
6976 }
6977
6978 #[test]
6979 fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
6980 let path =
6981 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6982 let schema = json!({
6983 "version": 1,
6984 "tables": [{
6985 "name": "tasks",
6986 "primaryKey": "id",
6987 "columns": [
6988 { "name": "id", "type": "string", "nullable": false },
6989 { "name": "project_id", "type": "string", "nullable": false }
6990 ],
6991 "scopes": [{ "pattern": "project:{project_id}" }]
6992 }]
6993 });
6994 let mut client = SyncClient::open_path_with_identity(
6995 Some("sidecar-delivery".to_owned()),
6996 &schema,
6997 ClientLimits::default(),
6998 path.to_str().expect("UTF-8 temp path"),
6999 )
7000 .expect("open owner");
7001 client.begin_observation("test_delivery").expect("observe");
7002 let operations = [OutboxOp {
7003 upsert: true,
7004 table: "tasks".to_owned(),
7005 row_id: "t1".to_owned(),
7006 base_version: Some(1),
7007 values: Some(Map::from_iter([
7008 ("id".into(), json!("t1")),
7009 ("project_id".into(), json!("p1")),
7010 ])),
7011 }];
7012 client
7013 .persist_commit_outcome(
7014 "conflict-commit",
7015 CommitOutcomeStatus::Conflict,
7016 &[CommitOperationOutcome::Conflict {
7017 conflict: ConflictRecord {
7018 client_commit_id: "conflict-commit".to_owned(),
7019 op_index: 0,
7020 table: "tasks".to_owned(),
7021 row_id: "t1".to_owned(),
7022 code: "sync.version_conflict".to_owned(),
7023 message: "stale base version".to_owned(),
7024 server_version: 2,
7025 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
7026 conflict_columns: vec!["project_id".to_owned()],
7027 operation: None,
7028 },
7029 }],
7030 Some(&operations),
7031 )
7032 .expect("persist conflict");
7033 client
7034 .persist_commit_outcome(
7035 "rejected-commit",
7036 CommitOutcomeStatus::Rejected,
7037 &[CommitOperationOutcome::Error {
7038 rejection: RejectionRecord {
7039 client_commit_id: "rejected-commit".to_owned(),
7040 op_index: 0,
7041 code: "sync.unknown_table".to_owned(),
7042 message: "unknown table".to_owned(),
7043 retryable: false,
7044 details: None,
7045 operation: None,
7046 },
7047 }],
7048 Some(&operations),
7049 )
7050 .expect("persist rejection");
7051 client
7052 .finish_observation(
7053 "test_delivery",
7054 ChangeAccumulator {
7055 conflicts: true,
7056 outcomes: true,
7057 ..ChangeAccumulator::default()
7058 },
7059 )
7060 .expect("commit outcomes");
7061
7062 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7063 match reader.commit_delivery("conflict-commit").expect("conflict") {
7064 CommitDelivery::Known { outcome, .. } => {
7065 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7066 assert!(matches!(
7067 outcome.results.as_slice(),
7068 [CommitOperationOutcome::Conflict { conflict }]
7069 if conflict.code == "sync.version_conflict"
7070 && conflict.server_version == 2
7071 ));
7072 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7073 assert!(
7074 outcome.retained_rows.is_none(),
7075 "the sidecar reports the persisted view, not owner-derived retained rows"
7076 );
7077 }
7078 other => panic!("expected a known conflict: {other:?}"),
7079 }
7080 match reader
7081 .commit_delivery("rejected-commit")
7082 .expect("rejection")
7083 {
7084 CommitDelivery::Known { outcome, .. } => {
7085 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
7086 assert!(matches!(
7087 outcome.results.as_slice(),
7088 [CommitOperationOutcome::Error { rejection }]
7089 if rejection.code == "sync.unknown_table"
7090 ));
7091 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7092 assert!(outcome.retained_rows.is_none());
7093 }
7094 other => panic!("expected a known rejection: {other:?}"),
7095 }
7096 drop(reader);
7097 drop(client);
7098 std::fs::remove_file(path).expect("remove temp database");
7099 }
7100
7101 #[test]
7102 fn snapshot_read_isolates_a_concurrent_writer_commit() {
7103 use std::sync::mpsc::channel;
7104 let path = std::env::temp_dir().join(format!(
7105 "syncular-read-concurrent-{}.db",
7106 uuid::Uuid::new_v4()
7107 ));
7108 let schema = json!({
7109 "version": 1,
7110 "tables": [{
7111 "name": "tasks",
7112 "primaryKey": "id",
7113 "columns": [
7114 { "name": "id", "type": "string", "nullable": false },
7115 { "name": "project_id", "type": "string", "nullable": false }
7116 ],
7117 "scopes": [{ "pattern": "project:{project_id}" }]
7118 }]
7119 });
7120 let mut client = SyncClient::open_path_with_identity(
7121 Some("sidecar-concurrent".to_owned()),
7122 &schema,
7123 ClientLimits::default(),
7124 path.to_str().expect("UTF-8 temp path"),
7125 )
7126 .expect("open owner");
7127 client
7128 .mutate(vec![Mutation::Upsert {
7129 table: "tasks".to_owned(),
7130 values: Map::from_iter([
7131 ("id".to_owned(), Value::from("t1")),
7132 ("project_id".to_owned(), Value::from("one")),
7133 ]),
7134 base_version: None,
7135 }])
7136 .expect("seed row");
7137 let revision_before = client.local_revision().to_string();
7138
7139 let (ready_tx, ready_rx) = channel::<()>();
7142 let (done_tx, done_rx) = channel::<()>();
7143 let writer_path = path.to_string_lossy().into_owned();
7144 let writer_schema = schema.clone();
7145 let writer = std::thread::spawn(move || {
7146 let mut writer = SyncClient::open_path_with_identity(
7147 Some("sidecar-concurrent".to_owned()),
7148 &writer_schema,
7149 ClientLimits::default(),
7150 &writer_path,
7151 )
7152 .expect("open writer");
7153 ready_rx.recv().expect("reader signalled");
7154 writer
7155 .mutate(vec![Mutation::Upsert {
7156 table: "tasks".to_owned(),
7157 values: Map::from_iter([
7158 ("id".to_owned(), Value::from("t2")),
7159 ("project_id".to_owned(), Value::from("one")),
7160 ]),
7161 base_version: None,
7162 }])
7163 .expect("writer commit");
7164 let _ = done_tx.send(());
7165 });
7166 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7167 SNAPSHOT_READ_HOOK.with(|slot| {
7168 *slot.borrow_mut() = Some(Box::new(move || {
7169 ready_tx.send(()).expect("signal writer");
7170 done_rx.recv().expect("writer finished");
7171 }));
7172 });
7173 let read = reader
7174 .snapshot_read(&SnapshotReadRequest {
7175 statements: vec![
7176 SnapshotStatement {
7177 sql: "SELECT count(*) AS n FROM tasks",
7178 params: &[],
7179 },
7180 SnapshotStatement {
7181 sql: "SELECT count(*) AS n FROM tasks",
7182 params: &[],
7183 },
7184 ],
7185 coverage: &[],
7186 subscriptions: Vec::new(),
7187 commit_ids: Vec::new(),
7188 })
7189 .expect("snapshot read");
7190 assert_eq!(
7191 read.queries[0][0]["n"], 1,
7192 "first statement sees the pre-commit row"
7193 );
7194 assert_eq!(
7195 read.queries[1][0]["n"], 1,
7196 "a later statement keeps the one read snapshot"
7197 );
7198 assert_eq!(
7199 read.revision, revision_before,
7200 "the read reports the snapshot revision, not the writer's"
7201 );
7202 writer.join().expect("join writer");
7203
7204 assert_ne!(
7205 reader.local_revision().expect("revision after commit"),
7206 revision_before,
7207 "the next read observes the committed revision"
7208 );
7209 let after = reader
7210 .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
7211 .expect("count after commit");
7212 assert_eq!(
7213 after.rows[0]["n"], 2,
7214 "the committed row is visible afterwards"
7215 );
7216
7217 drop(reader);
7218 drop(client);
7219 std::fs::remove_file(path).expect("remove temp database");
7220 }
7221
7222 #[test]
7223 fn snapshot_read_rejects_every_non_canonical_revision() {
7224 let path = std::env::temp_dir().join(format!(
7225 "syncular-read-revision-{}.db",
7226 uuid::Uuid::new_v4()
7227 ));
7228 let schema = json!({
7229 "version": 1,
7230 "tables": [{
7231 "name": "tasks",
7232 "primaryKey": "id",
7233 "columns": [
7234 { "name": "id", "type": "string", "nullable": false },
7235 { "name": "project_id", "type": "string", "nullable": false }
7236 ],
7237 "scopes": [{ "pattern": "project:{project_id}" }]
7238 }]
7239 });
7240 let client = SyncClient::open_path_with_identity(
7241 Some("sidecar-revision".to_owned()),
7242 &schema,
7243 ClientLimits::default(),
7244 path.to_str().expect("UTF-8 temp path"),
7245 )
7246 .expect("open owner");
7247 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7248 for bad in [
7249 "+1",
7250 "01",
7251 "-1",
7252 " 1",
7253 "1 ",
7254 "18446744073709551616",
7255 "1.0",
7256 "abc",
7257 "",
7258 ] {
7259 client
7260 .conn
7261 .execute(
7262 "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
7263 rusqlite::params![bad],
7264 )
7265 .expect("corrupt revision");
7266 let failure = reader.local_revision().expect_err("corrupt marker fails");
7267 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
7268 }
7269 for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
7270 client
7271 .conn
7272 .execute_batch(&format!(
7273 "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
7274 ))
7275 .expect("corrupt stored revision type");
7276 assert_eq!(
7277 reader.local_revision().expect_err("invalid SQL field").code,
7278 Some("sync.local_corrupt")
7279 );
7280 }
7281 client
7283 .conn
7284 .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
7285 .expect("delete marker");
7286 assert_eq!(reader.local_revision().expect("legacy zero"), "0");
7287
7288 drop(reader);
7289 drop(client);
7290 std::fs::remove_file(path).expect("remove temp database");
7291 }
7292
7293 #[test]
7294 fn snapshot_read_rejects_corrupt_subscription_state() {
7295 let path = std::env::temp_dir().join(format!(
7296 "syncular-read-substate-{}.db",
7297 uuid::Uuid::new_v4()
7298 ));
7299 let schema = json!({
7300 "version": 1,
7301 "tables": [{
7302 "name": "tasks",
7303 "primaryKey": "id",
7304 "columns": [
7305 { "name": "id", "type": "string", "nullable": false },
7306 { "name": "project_id", "type": "string", "nullable": false }
7307 ],
7308 "scopes": [{ "pattern": "project:{project_id}" }]
7309 }]
7310 });
7311 let mut client = SyncClient::open_path_with_identity(
7312 Some("sidecar-substate".to_owned()),
7313 &schema,
7314 ClientLimits::default(),
7315 path.to_str().expect("UTF-8 temp path"),
7316 )
7317 .expect("open owner");
7318 client
7319 .subscribe(
7320 "s1".into(),
7321 "tasks".into(),
7322 vec![("project_id".into(), vec!["one".into()])],
7323 None,
7324 )
7325 .expect("subscribe");
7326 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7327 for bad in [
7328 "not json".to_owned(),
7329 "{}".to_owned(),
7330 json!({ "status": "bogus", "cursor": 0 }).to_string(),
7331 json!({ "status": "active", "cursor": "x" }).to_string(),
7332 json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
7333 json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
7334 ] {
7335 client
7336 .conn
7337 .execute(
7338 "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
7339 rusqlite::params![bad],
7340 )
7341 .expect("corrupt subscription state");
7342 let failure = reader
7343 .subscription_catchup("s1")
7344 .expect_err("corrupt state fails");
7345 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
7346 }
7347
7348 for assignment in [
7349 "tbl=X'7461736b73'",
7350 "state_json=X'7b7d'",
7351 "state_json=CAST(X'FF' AS TEXT)",
7352 ] {
7353 client.conn.execute_batch(&format!(
7354 "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
7355 )).expect("corrupt stored subscription type");
7356 assert_eq!(
7357 reader
7358 .subscription_catchup("s1")
7359 .expect_err("invalid SQL field")
7360 .code,
7361 Some("sync.local_corrupt")
7362 );
7363 }
7364
7365 drop(reader);
7366 drop(client);
7367 std::fs::remove_file(path).expect("remove temp database");
7368 }
7369
7370 #[test]
7371 fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
7372 let path = std::env::temp_dir().join(format!(
7373 "syncular-read-rollback-{}.db",
7374 uuid::Uuid::new_v4()
7375 ));
7376 let schema = json!({
7377 "version": 1,
7378 "tables": [{
7379 "name": "tasks",
7380 "primaryKey": "id",
7381 "columns": [
7382 { "name": "id", "type": "string", "nullable": false },
7383 { "name": "project_id", "type": "string", "nullable": false }
7384 ],
7385 "scopes": [{ "pattern": "project:{project_id}" }]
7386 }]
7387 });
7388 let mut client = SyncClient::open_path_with_identity(
7389 Some("sidecar-rollback".to_owned()),
7390 &schema,
7391 ClientLimits::default(),
7392 path.to_str().expect("UTF-8 temp path"),
7393 )
7394 .expect("open owner");
7395 let commit_id = client
7396 .mutate(vec![Mutation::Upsert {
7397 table: "tasks".to_owned(),
7398 values: Map::from_iter([
7399 ("id".to_owned(), Value::from("t1")),
7400 ("project_id".to_owned(), Value::from("one")),
7401 ]),
7402 base_version: None,
7403 }])
7404 .expect("seed row");
7405 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7406 let revision = reader.local_revision().expect("revision");
7407
7408 let failure = reader
7411 .snapshot_read(&SnapshotReadRequest {
7412 statements: vec![
7413 SnapshotStatement {
7414 sql: "SELECT id FROM tasks",
7415 params: &[],
7416 },
7417 SnapshotStatement {
7418 sql: "SELECT * FROM does_not_exist",
7419 params: &[],
7420 },
7421 ],
7422 coverage: &[],
7423 subscriptions: Vec::new(),
7424 commit_ids: vec![commit_id.clone()],
7425 })
7426 .expect_err("second statement fails");
7427 assert_eq!(failure.code, None);
7428 assert_eq!(
7429 reader.local_revision().expect("revision after rollback"),
7430 revision,
7431 "a rolled-back read consumes no revision"
7432 );
7433 assert!(
7434 !matches!(
7435 reader
7436 .commit_delivery(&commit_id)
7437 .expect("delivery after rollback"),
7438 CommitDelivery::Unknown { .. }
7439 ),
7440 "the same reader serves the next read after releasing the connection"
7441 );
7442
7443 let guard = reader
7445 .snapshot_read(&SnapshotReadRequest {
7446 statements: vec![SnapshotStatement {
7447 sql: "DELETE FROM tasks",
7448 params: &[],
7449 }],
7450 coverage: &[],
7451 subscriptions: Vec::new(),
7452 commit_ids: Vec::new(),
7453 })
7454 .expect_err("write guard");
7455 assert!(guard.message.contains("read-only"), "{}", guard.message);
7456
7457 drop(reader);
7458 drop(client);
7459 std::fs::remove_file(path).expect("remove temp database");
7460 }
7461
7462 #[test]
7463 fn owned_query_failures_classify_order_bound_and_clear() {
7464 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7465 rusqlite::ffi::Error::new(266),
7466 Some("private SQLite prose".to_owned()),
7467 ));
7468 assert_eq!(io.code, Some("client.storage_io"));
7469 assert_eq!(io.sqlite_code, Some(266));
7470 assert_eq!(io.message, "local SQLite storage I/O failed");
7471 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7472 rusqlite::ffi::Error::new(26),
7473 None,
7474 ));
7475 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
7476 assert!(!io.retryable());
7477 assert!(!corrupt.retryable());
7478
7479 for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
7482 let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7483 rusqlite::ffi::Error::new(raw),
7484 Some("database is locked".to_owned()),
7485 ));
7486 assert_eq!(busy.code, Some("client.storage_busy"));
7487 assert_eq!(busy.sqlite_code, Some(extended));
7488 assert_eq!(busy.message, "local SQLite storage is busy");
7489 assert!(busy.retryable());
7490 }
7491 let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7492 rusqlite::ffi::Error::new(1),
7493 Some("generic SQLite prose".to_owned()),
7494 ));
7495 assert_eq!(unrelated.code, None);
7496 assert!(!unrelated.retryable());
7497
7498 let mut client = client();
7499 let generic = QueryReadFailure::from("private query prose".to_owned());
7500 client.record_query_read(
7501 QueryOwner {
7502 id: "first",
7503 tables: &["tasks", "tasks"],
7504 },
7505 Some(&generic),
7506 );
7507 client.record_query_read(
7508 QueryOwner {
7509 id: "second",
7510 tables: &["tasks"],
7511 },
7512 Some(&generic),
7513 );
7514 client.record_query_read(
7515 QueryOwner {
7516 id: "first",
7517 tables: &["docs"],
7518 },
7519 Some(&io),
7520 );
7521 let failures = &client
7522 .diagnostics_snapshot(&Default::default())
7523 .expect("diagnostics")
7524 .query_failures;
7525 assert_eq!(
7526 failures
7527 .iter()
7528 .map(|entry| entry.id.as_str())
7529 .collect::<Vec<_>>(),
7530 vec!["second", "first"]
7531 );
7532 assert_eq!(failures[1].tables, vec!["docs"]);
7533 assert_eq!(failures[1].code, "client.storage_io");
7534
7535 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
7536 client.record_query_read(
7537 QueryOwner {
7538 id: &format!("query-{index}"),
7539 tables: &["tasks"],
7540 },
7541 Some(&generic),
7542 );
7543 }
7544 let failures = &client
7545 .diagnostics_snapshot(&Default::default())
7546 .expect("bounded diagnostics")
7547 .query_failures;
7548 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
7549 assert_eq!(failures.last().expect("last failure").id, "query-256");
7550
7551 client
7552 .query_snapshot(
7553 "SELECT id FROM tasks",
7554 &[],
7555 &[],
7556 Some(QueryOwner {
7557 id: "query-256",
7558 tables: &["tasks"],
7559 }),
7560 )
7561 .expect("successful owned read");
7562 assert!(client
7563 .diagnostics_snapshot(&Default::default())
7564 .expect("cleared diagnostics")
7565 .query_failures
7566 .iter()
7567 .all(|entry| entry.id != "query-256"));
7568 }
7569
7570 #[test]
7571 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
7572 let mut client = client();
7573 let image = Connection::open_in_memory().unwrap();
7574 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
7575 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7576 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
7577 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7578 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
7579 let table = client.schema.table("tasks").unwrap().clone();
7580 let scopes = vec![("project_id".into(), vec!["p1".into()])];
7581 assert!(client
7582 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
7583 .is_err());
7584 assert!(client.conn.is_autocommit());
7585 assert_eq!(
7586 client
7587 .query("SELECT count(*) AS n FROM tasks", &[])
7588 .unwrap()[0]["n"],
7589 1024
7590 );
7591 assert_eq!(client.drain_change_batches().len(), 1);
7592 image
7593 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
7594 .unwrap();
7595 assert!(matches!(
7596 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
7597 Ok(2050)
7598 ));
7599 assert_eq!(
7600 client
7601 .query("SELECT count(*) AS n FROM tasks", &[])
7602 .unwrap()[0]["n"],
7603 2050
7604 );
7605 assert_eq!(client.drain_change_batches().len(), 3);
7606 }
7607
7608 #[test]
7609 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
7610 let mut client = client();
7611 let base = WindowBase {
7612 table: "tasks".into(),
7613 variable: "project_id".into(),
7614 fixed_scopes: Vec::new(),
7615 params: None,
7616 };
7617 client
7618 .set_window(&base, &["p1".into(), "p2".into()])
7619 .unwrap();
7620 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7621 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7622 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
7623 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7624 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7625 client.drain_change_batches();
7626 assert!(client.set_window(&base, &["p2".into()]).is_err());
7627 assert!(client.conn.is_autocommit());
7628 assert_eq!(
7629 client
7630 .query("SELECT count(*) AS n FROM tasks", &[])
7631 .unwrap()[0]["n"],
7632 1027
7633 );
7634 assert_eq!(client.window_state(&base).units, vec!["p2"]);
7635 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
7636 assert_eq!(client.drain_change_batches().len(), 1);
7637 client
7638 .conn
7639 .execute_batch("DROP TRIGGER interrupt_evict")
7640 .unwrap();
7641 client.drain_pending_evictions().unwrap();
7642 assert!(client.load_pending_evictions().unwrap().is_empty());
7643 assert_eq!(
7644 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
7645 "held"
7646 );
7647 assert_eq!(client.drain_change_batches().len(), 2);
7648 }
7649
7650 #[test]
7651 fn eviction_marker_failures_roll_back_window_changes() {
7652 let mut client = client();
7653 let base = WindowBase {
7654 table: "tasks".into(),
7655 variable: "project_id".into(),
7656 fixed_scopes: Vec::new(),
7657 params: None,
7658 };
7659 client.set_window(&base, &["p1".into()]).unwrap();
7660 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7661 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7662 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7663 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7664 let revision = client.local_revision();
7665 assert!(client.set_window(&base, &[]).is_err());
7666 assert!(client.conn.is_autocommit());
7667 assert_eq!(client.local_revision(), revision);
7668 assert_eq!(
7669 client
7670 .query("SELECT count(*) AS n FROM tasks", &[])
7671 .unwrap()[0]["n"],
7672 2050
7673 );
7674 assert_eq!(client.window_state(&base).units, vec!["p1"]);
7675 client.conn.execute_batch("DROP TRIGGER fail_marker;
7676 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7677 assert!(client.set_window(&base, &[]).is_err());
7678 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
7679 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7680 assert!(client.drain_pending_evictions().is_err());
7681 assert!(client.conn.is_autocommit());
7682 assert_eq!(
7683 client
7684 .query("SELECT count(*) AS n FROM tasks", &[])
7685 .unwrap()[0]["n"],
7686 2
7687 );
7688 assert!(client.set_window(&base, &["p1".into()]).is_err());
7689 assert!(client.conn.is_autocommit());
7690 assert!(client.window_state(&base).units.is_empty());
7691 assert!(client.subs.is_empty());
7692 assert_eq!(
7693 client
7694 .query(
7695 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
7696 &[]
7697 )
7698 .unwrap()[0]["n"],
7699 1
7700 );
7701 client
7702 .conn
7703 .execute_batch("DROP TRIGGER fail_marker")
7704 .unwrap();
7705 client.set_window(&base, &["p1".into()]).unwrap();
7706 assert_eq!(client.window_state(&base).units, vec!["p1"]);
7707 }
7708
7709 #[test]
7710 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
7711 let mut client = client();
7712 client
7713 .conn
7714 .execute(
7715 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
7716 [],
7717 )
7718 .unwrap();
7719 assert_eq!(
7720 client.drain_pending_evictions().unwrap_err(),
7721 "sync.local_corrupt: invalid pending eviction scopes"
7722 );
7723 assert!(client.conn.is_autocommit());
7724 assert!(client.enqueue_startup_sync_if_needed().is_err());
7725 }
7726
7727 #[test]
7728 fn image_chunks_reject_null_primary_keys() {
7729 let mut client = client();
7730 let image = Connection::open_in_memory().unwrap();
7731 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7732 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
7733 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
7734 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7735 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
7736 let table = client.schema.table("tasks").unwrap().clone();
7737 assert!(client
7738 .apply_sqlite_image(
7739 &image,
7740 &table,
7741 true,
7742 &[("project_id".into(), vec!["p1".into()])],
7743 1025,
7744 7,
7745 "digest"
7746 )
7747 .is_err());
7748 assert!(client.conn.is_autocommit());
7749 assert_eq!(
7750 client
7751 .query("SELECT count(*) AS n FROM tasks", &[])
7752 .unwrap()[0]["n"],
7753 0
7754 );
7755 }
7756
7757 #[test]
7758 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
7759 for images in [false, true] {
7760 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7761 "name":name,"primaryKey":"id","columns":[
7762 {"name":"id","type":"string","nullable":false},
7763 {"name":"project_id","type":"string","nullable":false},
7764 {"name":"title","type":"string","nullable":false}],
7765 "scopes":[{"pattern":"project:{project_id}"}],
7766 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
7767 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7768 })))});
7769 let mut actual =
7770 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
7771 let mut reference =
7772 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
7773 let scopes = vec![("project_id".into(), vec!["p1".into()])];
7774 for client in [&mut actual, &mut reference] {
7775 client
7776 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
7777 .unwrap();
7778 client.subs[0].effective = Some(scopes.clone());
7779 for (table, id, title) in [
7780 ("tasks", "a", "original"),
7781 ("tasks", "b", "occupied"),
7782 ("tasks", "c", "deleted"),
7783 ("notes", "n", "untouched"),
7784 ] {
7785 client
7786 .write_base_row(
7787 table,
7788 &vec![
7789 Some(ColumnValue::String(id.into())),
7790 Some(ColumnValue::String("p1".into())),
7791 Some(ColumnValue::String(title.into())),
7792 ],
7793 1,
7794 )
7795 .unwrap();
7796 }
7797 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7798 client
7799 .mutate(vec![
7800 Mutation::Upsert {
7801 table: "tasks".into(),
7802 values: Map::from_iter([
7803 ("id".into(), Value::from("a")),
7804 ("project_id".into(), Value::from("p1")),
7805 ("title".into(), Value::from("available")),
7806 ]),
7807 base_version: None,
7808 },
7809 Mutation::Delete {
7810 table: "tasks".into(),
7811 row_id: "c".into(),
7812 base_version: None,
7813 },
7814 Mutation::Upsert {
7815 table: "tasks".into(),
7816 values: Map::from_iter([
7817 ("id".into(), Value::from("local")),
7818 ("project_id".into(), Value::from("p1")),
7819 ("title".into(), Value::from("local value")),
7820 ]),
7821 base_version: None,
7822 },
7823 Mutation::Upsert {
7824 table: "notes".into(),
7825 values: Map::from_iter([
7826 ("id".into(), Value::from("n")),
7827 ("project_id".into(), Value::from("p1")),
7828 ("title".into(), Value::from("pending note")),
7829 ]),
7830 base_version: None,
7831 },
7832 ])
7833 .unwrap();
7834 }
7835 let pending = actual.pending_commit_ids();
7836 actual.overlay_rebuild_count.set(0);
7837 for (id, title) in [
7838 ("b", "available"),
7839 ("b", "occupied"),
7840 ("a", "server original"),
7841 ("d", "local value"),
7842 ("d", "released"),
7843 ("c", "redelivered"),
7844 ] {
7845 let table = actual.schema.table("tasks").unwrap().clone();
7846 let values = vec![
7847 Some(ColumnValue::String(id.into())),
7848 Some(ColumnValue::String("p1".into())),
7849 Some(ColumnValue::String(title.into())),
7850 ];
7851 if images {
7852 let image = Connection::open_in_memory().unwrap();
7853 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7854 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7855 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
7856 image
7857 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
7858 .unwrap();
7859 assert!(matches!(
7860 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
7861 Ok(1)
7862 ));
7863 } else {
7864 let segment = RowsSegment {
7865 table: "tasks".into(),
7866 schema_version: 1,
7867 columns: table.wire_columns.clone(),
7868 blocks: vec![vec![ssp2::segment::SegmentRow {
7869 server_version: 2,
7870 values: values.clone(),
7871 }]],
7872 };
7873 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
7874 }
7875 reference.write_base_row("tasks", &values, 2).unwrap();
7876 reference.rebuild_overlay().expect("rebuild overlay");
7877 for table in ["tasks", "notes"] {
7878 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")] {
7879 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
7880 }
7881 }
7882 assert_eq!(actual.pending_commit_ids(), pending);
7883 }
7884 assert_eq!(
7885 actual.overlay_rebuild_count.get(),
7886 0,
7887 "each chunk must leave unrelated tables alone"
7888 );
7889 let before = actual
7890 .query("SELECT * FROM tasks ORDER BY id", &[])
7891 .unwrap();
7892 let fts_before = actual
7893 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
7894 .unwrap();
7895 let revision = actual.local_revision();
7896 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
7897 let table = actual.schema.table("tasks").unwrap().clone();
7898 let segment = RowsSegment {
7899 table: "tasks".into(),
7900 schema_version: 1,
7901 columns: table.wire_columns.clone(),
7902 blocks: vec![vec![ssp2::segment::SegmentRow {
7903 server_version: 3,
7904 values: vec![
7905 Some(ColumnValue::String("b".into())),
7906 Some(ColumnValue::String("p1".into())),
7907 Some(ColumnValue::String("fresh".into())),
7908 ],
7909 }]],
7910 };
7911 assert!(actual.apply_segment(0, &segment, false).is_err());
7912 assert!(actual.conn.is_autocommit());
7913 assert_eq!(
7914 actual
7915 .query("SELECT * FROM tasks ORDER BY id", &[])
7916 .unwrap(),
7917 before
7918 );
7919 assert_eq!(
7920 actual
7921 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
7922 .unwrap(),
7923 fts_before
7924 );
7925 assert_eq!(actual.local_revision(), revision);
7926 assert_eq!(actual.pending_commit_ids(), pending);
7927 }
7928 }
7929
7930 #[test]
7931 fn import_reconciliation_preserves_typed_pending_primary_keys() {
7932 for (kind, a, b) in [
7933 ("string", json!("a"), json!("b")),
7934 ("integer", json!(1), json!(2)),
7935 ("boolean", json!(true), json!(false)),
7936 ("json", json!("1"), json!("2")),
7937 ] {
7938 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}]}]});
7939 for images in [false, true] {
7940 let mut client =
7941 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
7942 let table = client.schema.table("tasks").unwrap().clone();
7943 client
7944 .subscribe("tasks".into(), "tasks".into(), vec![], None)
7945 .unwrap();
7946 for (id, title) in [(&a, "original"), (&b, "occupied")] {
7947 client
7948 .write_base_row(
7949 "tasks",
7950 &vec![
7951 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
7952 Some(ColumnValue::String(title.into())),
7953 ],
7954 1,
7955 )
7956 .unwrap();
7957 }
7958 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7959 client
7960 .mutate(vec![Mutation::Upsert {
7961 table: "tasks".into(),
7962 values: Map::from_iter([
7963 ("id".into(), a.clone()),
7964 ("title".into(), json!("available")),
7965 ]),
7966 base_version: None,
7967 }])
7968 .unwrap();
7969 client.overlay_rebuild_count.set(0);
7970 for (title, expected) in [("available", "original"), ("occupied", "available")] {
7971 let values = vec![
7972 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
7973 Some(ColumnValue::String(title.into())),
7974 ];
7975 if images {
7976 let image = Connection::open_in_memory().unwrap();
7977 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7978 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7979 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
7980 image
7981 .execute(
7982 "INSERT INTO tasks VALUES (?1,?2,2)",
7983 rusqlite::params![RowParam::Cell(&values[0]), title],
7984 )
7985 .unwrap();
7986 assert!(
7987 matches!(
7988 client.apply_sqlite_image(
7989 &image,
7990 &table,
7991 false,
7992 &[],
7993 1,
7994 7,
7995 "digest"
7996 ),
7997 Ok(1)
7998 ),
7999 "{kind}, images={images}"
8000 );
8001 } else {
8002 let segment = RowsSegment {
8003 table: "tasks".into(),
8004 schema_version: 1,
8005 columns: table.wire_columns.clone(),
8006 blocks: vec![vec![ssp2::segment::SegmentRow {
8007 server_version: 2,
8008 values,
8009 }]],
8010 };
8011 assert!(
8012 matches!(client.apply_segment(0, &segment, false), Ok(1)),
8013 "{kind}"
8014 );
8015 }
8016 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
8017 let actual: String = client
8018 .conn
8019 .query_row(
8020 "SELECT title FROM tasks WHERE id=?1",
8021 [RowParam::Cell(&key)],
8022 |row| row.get(0),
8023 )
8024 .unwrap();
8025 assert_eq!(actual, expected, "{kind}, images={images}");
8026 }
8027 assert_eq!(client.overlay_rebuild_count.get(), 0);
8028 }
8029 }
8030 }
8031
8032 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
8033 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8034 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
8035 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
8036 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8037 })))});
8038 let mut client =
8039 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
8040 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();
8041 client.rebuild_overlay().expect("rebuild overlay");
8042 client
8043 .mutate(vec![Mutation::Upsert {
8044 table: "tasks".into(),
8045 values: Map::from_iter([
8046 ("id".into(), json!("offline")),
8047 ("project_id".into(), json!("p1")),
8048 ("title".into(), json!("pending edit")),
8049 ]),
8050 base_version: None,
8051 }])
8052 .unwrap();
8053 let image = Connection::open_in_memory().unwrap();
8054 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8055 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
8056 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();
8057 image
8058 .execute(
8059 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
8060 [rows],
8061 )
8062 .unwrap();
8063 client.overlay_rebuild_count.set(0);
8064 let table = client.schema.table("tasks").unwrap().clone();
8065 (client, image, table)
8066 }
8067
8068 #[test]
8069 fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
8070 let (mut client, image, table) = unique_import_fixture(1);
8071 image
8072 .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
8073 .unwrap();
8074 client
8075 .conn
8076 .execute_batch("PRAGMA max_page_count = 1")
8077 .unwrap();
8078 let effective = [("project_id".into(), vec!["p1".into()])];
8079 assert!(client
8080 .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
8081 .is_err());
8082 let failure = client.storage_failure.borrow().clone().unwrap();
8083 assert_eq!(failure.code, Some("client.storage_full"));
8084 assert_eq!(failure.sqlite_code, Some(13));
8085 assert!(!failure.message.contains("rollback"));
8086 assert!(client.conn.is_autocommit());
8087 client
8088 .conn
8089 .execute_batch("PRAGMA max_page_count = 1073741823")
8090 .unwrap();
8091 assert!(matches!(
8092 client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
8093 Ok(1)
8094 ));
8095 assert!(client.conn.is_autocommit());
8096 }
8097
8098 #[test]
8099 fn authoring_storage_failures_are_structured_and_scoped() {
8100 let path = std::env::temp_dir().join(format!(
8101 "syncular-authoring-failure-{}.db",
8102 uuid::Uuid::new_v4()
8103 ));
8104 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8105 {"name":"id","type":"string","nullable":false},
8106 {"name":"project_id","type":"string","nullable":false},
8107 {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8108 let mut client = SyncClient::open_path(
8109 "authoring".into(),
8110 &schema,
8111 ClientLimits::default(),
8112 path.to_str().unwrap(),
8113 )
8114 .expect("open client");
8115 client.create_synced_tables().expect("create synced tables");
8116 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8118
8119 let upsert = |id: &str| {
8120 vec![Mutation::Upsert {
8121 table: "tasks".into(),
8122 values: Map::from_iter([
8123 ("id".into(), json!(id)),
8124 ("project_id".into(), json!("p1")),
8125 ("title".into(), json!("first")),
8126 ]),
8127 base_version: None,
8128 }]
8129 };
8130
8131 let locker = Connection::open(&path).expect("second connection");
8132 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8133 let busy = client.mutate(upsert("t1")).expect_err("busy write");
8134 assert_eq!(busy.code, "client.storage_busy");
8135 assert!(busy.retryable);
8136 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8137 assert!(client.pending_commit_ids().is_empty());
8138 assert_eq!(client.local_revision(), 0);
8139
8140 let invalid = client.mutate(Vec::new()).expect_err("empty commit");
8142 assert_eq!(invalid.code, "sync.invalid_request");
8143 assert_eq!(invalid.message, "the authoring request is invalid");
8144 assert!(
8145 invalid.details.as_ref().unwrap()["legacyCause"]
8146 .as_str()
8147 .unwrap()
8148 .contains("at least one operation"),
8149 "{invalid:?}"
8150 );
8151
8152 locker.execute_batch("ROLLBACK").unwrap();
8153 let id = client.mutate(upsert("t1")).expect("retry after busy");
8154 assert_eq!(client.pending_commit_ids(), vec![id]);
8155 assert_eq!(client.local_revision(), 1);
8156
8157 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8159 let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
8160 assert_eq!(busy_again.code, "client.storage_busy");
8161 assert!(busy_again.retryable);
8162 locker.execute_batch("ROLLBACK").unwrap();
8163
8164 let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
8166 rusqlite::ffi::Error::new(13),
8167 Some("database or disk is full".to_owned()),
8168 )));
8169 assert_eq!(full.code, "client.storage_full");
8170 assert!(!full.retryable);
8171 assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
8172
8173 drop(locker);
8174 drop(client);
8175 let _ = std::fs::remove_file(&path);
8176 }
8177
8178 #[test]
8179 fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
8180 let path = std::env::temp_dir().join(format!(
8181 "syncular-caller-values-{}.db",
8182 uuid::Uuid::new_v4()
8183 ));
8184 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8185 {"name":"id","type":"string","nullable":false},
8186 {"name":"project_id","type":"string","nullable":false},
8187 {"name":"title","type":"string","nullable":false},
8188 {"name":"priority","type":"integer","nullable":false},
8189 {"name":"payload","type":"bytes","nullable":false},
8190 {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8191 let mut client = SyncClient::open_path(
8192 "caller-values".into(),
8193 &schema,
8194 ClientLimits::default(),
8195 path.to_str().unwrap(),
8196 )
8197 .expect("open client");
8198 client.create_synced_tables().expect("create synced tables");
8199
8200 let values = |id: &str| {
8201 Map::from_iter([
8202 ("id".to_owned(), json!(id)),
8203 ("project_id".to_owned(), json!("p1")),
8204 ("title".to_owned(), json!("first")),
8205 ("priority".to_owned(), json!(1)),
8206 ("payload".to_owned(), json!({"$bytes": "0a"})),
8207 ])
8208 };
8209 let upsert = |values: Map<String, Value>| {
8210 vec![Mutation::Upsert {
8211 table: "tasks".into(),
8212 values,
8213 base_version: None,
8214 }]
8215 };
8216 let seed = client.mutate(upsert(values("t1"))).expect("seed row");
8217 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
8218
8219 for (label, override_value, cause) in [
8224 ("unknown column", ("nope", json!(1)), "unknown column"),
8225 (
8226 "internal sync column",
8227 ("_sync_version", json!(1)),
8228 "internal sync column",
8229 ),
8230 ("wrong value type", ("title", json!(7)), "expected a string"),
8231 (
8232 "malformed bytes envelope",
8233 ("payload", json!({"$bytes": "zz"})),
8234 "expected exactly",
8235 ),
8236 (
8237 "odd-length bytes envelope",
8238 ("payload", json!({"$bytes": "abc"})),
8239 "expected exactly",
8240 ),
8241 (
8242 "plus-sign bytes envelope",
8243 ("payload", json!({"$bytes": "+a"})),
8244 "expected exactly",
8245 ),
8246 (
8247 "bytes envelope with a second key",
8248 ("payload", json!({"$bytes": "0a", "extra": 1})),
8249 "expected exactly",
8250 ),
8251 (
8252 "no bytes envelope",
8253 ("payload", json!({"nested": true})),
8254 "expected exactly",
8255 ),
8256 ] {
8257 let mut full = values("t2");
8258 full.insert(override_value.0.to_owned(), override_value.1);
8259 let error = client.mutate(upsert(full)).expect_err(label);
8260 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8261 assert_eq!(error.message, "the authoring request is invalid", "{label}");
8262 assert!(!error.retryable, "{label}");
8263 let legacy = error.details.as_ref().unwrap()["legacyCause"]
8264 .as_str()
8265 .unwrap();
8266 assert!(
8267 legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
8268 "{label}: {legacy}"
8269 );
8270 assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
8271 assert_eq!(client.local_revision(), 1, "{label}");
8272 assert_eq!(
8273 client.query("SELECT * FROM tasks", &[]).unwrap(),
8274 rows,
8275 "{label}"
8276 );
8277 }
8278
8279 let mut missing = values("t3");
8282 missing.remove("payload");
8283 let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
8284 assert_eq!(missing_error.code, "sync.invalid_request");
8285 assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
8286 .as_str()
8287 .unwrap()
8288 .contains("is not nullable"));
8289
8290 let mut bad_row_id = values("t4");
8293 bad_row_id.insert("id".to_owned(), json!({"nested": true}));
8294 let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
8295 assert_eq!(row_id_error.code, "sync.invalid_request");
8296
8297 for (label, partial, cause) in [
8299 (
8300 "patch wrong value type",
8301 ("priority", json!("high")),
8302 "expected an integer",
8303 ),
8304 ("patch unknown column", ("nope", json!(1)), "unknown column"),
8305 ] {
8306 let error = client
8307 .patch(
8308 "tasks",
8309 "t1",
8310 Map::from_iter([(partial.0.to_owned(), partial.1)]),
8311 None,
8312 )
8313 .expect_err(label);
8314 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8315 assert_eq!(error.message, "the authoring request is invalid", "{label}");
8316 assert!(
8317 error.details.as_ref().unwrap()["legacyCause"]
8318 .as_str()
8319 .unwrap()
8320 .contains(cause),
8321 "{label}"
8322 );
8323 }
8324 assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
8325 assert_eq!(client.local_revision(), 1);
8326 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
8327
8328 for (label, payload) in [
8331 ("explicit null", json!(null)),
8332 ("empty envelope", json!({"$bytes": ""})),
8333 ("uppercase envelope", json!({"$bytes": "0AFF"})),
8334 ] {
8335 let mut accepted = values("t-ok");
8336 accepted.insert("extra".to_owned(), payload);
8337 let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
8338 panic!("{label} must be accepted: {error:?}");
8339 });
8340 assert!(!encoded.is_empty(), "{label}");
8341 }
8342
8343 let next = client
8345 .mutate(upsert(values("t5")))
8346 .expect("valid after failures");
8347 assert_ne!(next, seed);
8348
8349 drop(client);
8350 let _ = std::fs::remove_file(&path);
8351 }
8352
8353 #[test]
8354 fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
8355 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8356 {"name":"id","type":"string","nullable":false},
8357 {"name":"project_id","type":"string","nullable":false},
8358 {"name":"payload","type":"bytes","nullable":true}],
8359 "scopes":[{"pattern":"project:{project_id}"}]}]});
8360 let open = |path: &std::path::Path| {
8361 SyncClient::open_path(
8362 "legacy-values".into(),
8363 &schema,
8364 ClientLimits::default(),
8365 path.to_str().unwrap(),
8366 )
8367 .expect("open client")
8368 };
8369 let row = |id: &str| {
8370 Map::from_iter([
8371 ("id".to_owned(), json!(id)),
8372 ("project_id".to_owned(), json!("p1")),
8373 ("payload".to_owned(), json!({"$bytes": "0a"})),
8374 ])
8375 };
8376 let upsert = |values| {
8377 vec![Mutation::Upsert {
8378 table: "tasks".into(),
8379 values,
8380 base_version: None,
8381 }]
8382 };
8383 for (label, stored_cell, representable) in [
8384 ("plus-sign envelope", json!({"$bytes": "+a"}), false),
8385 ("non-hex envelope", json!({"$bytes": "zz"}), false),
8386 ("odd-length envelope", json!({"$bytes": "abc"}), false),
8387 ("no envelope", json!({"nested": true}), false),
8388 ("wrong declared type", json!("high"), true),
8392 ("stored null for a non-nullable column", json!(null), true),
8393 ] {
8394 let path = std::env::temp_dir().join(format!(
8395 "syncular-legacy-values-{}-{}.db",
8396 label.replace(' ', "-"),
8397 uuid::Uuid::new_v4()
8398 ));
8399 let mut client = open(&path);
8400 client.create_synced_tables().expect("create synced tables");
8401 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8402 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8403 drop(client);
8404
8405 let connection = Connection::open(&path).expect("raw connection");
8407 let (column, cell) = if stored_cell.is_null() {
8408 ("project_id", stored_cell.clone())
8409 } else {
8410 ("payload", stored_cell.clone())
8411 };
8412 let mut planted_values = json!({
8413 "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
8414 });
8415 planted_values[column] = cell;
8416 let planted = json!([{
8417 "op": "upsert",
8418 "table": "tasks",
8419 "rowId": "t1",
8420 "baseVersion": null,
8421 "values": planted_values
8422 }]);
8423 connection
8424 .execute(
8425 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8426 rusqlite::params![planted.to_string(), stale],
8427 )
8428 .expect("plant legacy intent");
8429 drop(connection);
8430
8431 let reopened = open(&path);
8434 assert_eq!(
8435 reopened.pending_commit_ids(),
8436 vec![successor.clone()],
8437 "{label}"
8438 );
8439 let rejection = reopened
8440 .rejections()
8441 .iter()
8442 .find(|rejection| rejection.client_commit_id == stale)
8443 .unwrap_or_else(|| panic!("{label}: rejection"));
8444 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8445 assert_eq!(
8446 rejection.message, "the persisted commit carries values the current codec refuses",
8447 "{label}"
8448 );
8449 assert_eq!(rejection.operation.is_some(), representable, "{label}");
8450 assert_eq!(
8451 rejection
8452 .details
8453 .as_ref()
8454 .and_then(|details| details.reason.as_deref()),
8455 Some(INVALID_STORED_VALUES_REASON),
8456 "{label}"
8457 );
8458 let journal = reopened
8460 .commit_outcome(&stale)
8461 .expect("read outcome")
8462 .expect("journal entry");
8463 assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
8464 drop(reopened);
8465 let mut reopened = open(&path);
8466 let reread = reopened
8467 .commit_outcome(&stale)
8468 .expect("read outcome")
8469 .expect("journal entry");
8470 assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
8471
8472 assert_eq!(
8475 reopened
8476 .query("SELECT id FROM tasks ORDER BY id", &[])
8477 .unwrap(),
8478 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8479 "{label}"
8480 );
8481 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8482 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8483 assert!(!reopened
8484 .drop_unencodable_outbox(false, None)
8485 .expect("idempotent prepass"));
8486 let mut transport = CountingRealtimeTransport::default();
8487 let outcome = reopened.sync(&mut transport);
8488 assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
8489 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8490 let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
8491 assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
8492 assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
8493 drop(reopened);
8494 let _ = std::fs::remove_file(&path);
8495 }
8496 }
8497
8498 #[test]
8499 fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
8500 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8501 {"name":"id","type":"string","nullable":false},
8502 {"name":"project_id","type":"string","nullable":false},
8503 {"name":"payload","type":"bytes","nullable":true}],
8504 "scopes":[{"pattern":"project:{project_id}"}]}]});
8505 for (label, column, planted) in [
8506 ("malformed envelope", "payload", json!({"$bytes": "zz"})),
8507 (
8508 "stored null for a non-nullable column",
8509 "project_id",
8510 json!(null),
8511 ),
8512 ("wrong declared type", "payload", json!(7)),
8513 ] {
8514 let path = std::env::temp_dir().join(format!(
8515 "syncular-shared-row-{}-{}.db",
8516 label.replace(' ', "-"),
8517 uuid::Uuid::new_v4()
8518 ));
8519 let open = |path: &std::path::Path| {
8520 SyncClient::open_path(
8521 "shared-row".into(),
8522 &schema,
8523 ClientLimits::default(),
8524 path.to_str().unwrap(),
8525 )
8526 .expect("open client")
8527 };
8528 let row = |id: &str| {
8529 Map::from_iter([
8530 ("id".to_owned(), json!(id)),
8531 ("project_id".to_owned(), json!("p1")),
8532 ("payload".to_owned(), json!({"$bytes": "0a"})),
8533 ])
8534 };
8535 let upsert = |values| {
8536 vec![Mutation::Upsert {
8537 table: "tasks".into(),
8538 values,
8539 base_version: None,
8540 }]
8541 };
8542 let mut client = open(&path);
8543 client.create_synced_tables().expect("create synced tables");
8544 let older = client.mutate(upsert(row("t1"))).expect("older commit");
8545 let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
8546 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8547 drop(client);
8548
8549 let connection = Connection::open(&path).expect("raw connection");
8550 for commit_id in [&older, &newer] {
8551 let mut values = row("t1");
8552 values.insert(column.to_owned(), planted.clone());
8553 let planted_ops = json!([{
8554 "op": "upsert",
8555 "table": "tasks",
8556 "rowId": "t1",
8557 "baseVersion": null,
8558 "values": values
8559 }]);
8560 connection
8561 .execute(
8562 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8563 rusqlite::params![planted_ops.to_string(), commit_id],
8564 )
8565 .expect("plant legacy intent");
8566 }
8567 drop(connection);
8568
8569 let mut reopened = open(&path);
8571 assert_eq!(
8572 reopened.pending_commit_ids(),
8573 vec![successor.clone()],
8574 "{label}"
8575 );
8576 assert_eq!(
8577 reopened
8578 .rejections()
8579 .iter()
8580 .map(|rejection| rejection.client_commit_id.clone())
8581 .collect::<Vec<_>>(),
8582 vec![older.clone(), newer.clone()],
8583 "{label}"
8584 );
8585 assert!(
8586 reopened
8587 .rejections()
8588 .iter()
8589 .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
8590 && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
8591 == Some(INVALID_STORED_VALUES_REASON)),
8592 "{label}"
8593 );
8594 for commit_id in [&older, &newer] {
8595 assert!(
8596 matches!(
8597 reopened
8598 .commit_outcome(commit_id)
8599 .expect("outcome")
8600 .map(|o| o.status),
8601 Some(CommitOutcomeStatus::Rejected)
8602 ),
8603 "{label}"
8604 );
8605 }
8606 assert_eq!(
8607 reopened
8608 .query("SELECT id FROM tasks ORDER BY id", &[])
8609 .unwrap(),
8610 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8611 "{label}"
8612 );
8613 let revision = reopened.local_revision();
8614 let batches = reopened.drain_change_batches();
8615 assert!(
8616 batches.iter().any(|batch| batch.rejections_changed
8617 && batch.outcomes_changed
8618 && batch.tables.iter().any(|change| change.table == "tasks")
8619 && batch.revision == revision.to_string()),
8620 "{label}: {batches:?}"
8621 );
8622 drop(reopened);
8623
8624 let mut stable = open(&path);
8627 assert_eq!(stable.local_revision(), revision, "{label}");
8628 assert!(stable.drain_change_batches().is_empty(), "{label}");
8629 assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
8630 drop(stable);
8631 let _ = std::fs::remove_file(&path);
8632 }
8633 }
8634
8635 #[test]
8636 fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
8637 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8638 {"name":"id","type":"string","nullable":false},
8639 {"name":"project_id","type":"string","nullable":false},
8640 {"name":"payload","type":"bytes","nullable":true}],
8641 "scopes":[{"pattern":"project:{project_id}"}]}]});
8642 for (label, second_op) in [
8643 (
8644 "delete carrying values",
8645 json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
8646 ),
8647 (
8648 "baseVersion beyond the safe bound",
8649 json!({"op": "upsert", "table": "tasks", "rowId": "t9",
8650 "baseVersion": MAX_JS_SAFE_INTEGER + 1,
8651 "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
8652 ),
8653 ] {
8654 let path = std::env::temp_dir().join(format!(
8655 "syncular-journal-shape-{}-{}.db",
8656 label.replace(' ', "-"),
8657 uuid::Uuid::new_v4()
8658 ));
8659 let open = |path: &std::path::Path| {
8660 SyncClient::open_path(
8661 "journal-shape".into(),
8662 &schema,
8663 ClientLimits::default(),
8664 path.to_str().unwrap(),
8665 )
8666 .expect("open client")
8667 };
8668 let values = |id: &str| {
8669 Map::from_iter([
8670 ("id".to_owned(), json!(id)),
8671 ("project_id".to_owned(), json!("p1")),
8672 ("payload".to_owned(), json!({"$bytes": "0a"})),
8673 ])
8674 };
8675 let upsert = |values| {
8676 vec![Mutation::Upsert {
8677 table: "tasks".into(),
8678 values,
8679 base_version: None,
8680 }]
8681 };
8682 let mut client = open(&path);
8683 client.create_synced_tables().expect("create synced tables");
8684 let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
8685 drop(client);
8686
8687 let connection = Connection::open(&path).expect("raw connection");
8688 let planted = json!([
8692 {
8693 "op": "upsert",
8694 "table": "tasks",
8695 "rowId": "t1",
8696 "baseVersion": null,
8697 "values": {"id": "t1", "project_id": null,
8698 "payload": {"$bytes": "0a"}}
8699 },
8700 second_op
8701 ]);
8702 connection
8703 .execute(
8704 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8705 rusqlite::params![planted.to_string(), stale],
8706 )
8707 .expect("plant legacy intent");
8708 drop(connection);
8709
8710 let reopened = open(&path);
8711 let journal = reopened
8712 .commit_outcome(&stale)
8713 .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
8714 .unwrap_or_else(|| panic!("{label}: journal entry"));
8715 assert!(
8716 matches!(journal.status, CommitOutcomeStatus::Rejected),
8717 "{label}"
8718 );
8719 assert!(journal.operations.is_none(), "{label}");
8720 assert!(
8721 reopened
8722 .commit_outcomes(CommitOutcomeQuery::default())
8723 .is_ok(),
8724 "{label}: outcome list query"
8725 );
8726 assert!(reopened.pending_commit_ids().is_empty(), "{label}");
8727 drop(reopened);
8728 let _ = std::fs::remove_file(&path);
8729 }
8730 }
8731
8732 #[test]
8733 fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
8734 let schema = |version: i32, extra: bool| {
8735 let mut columns = vec![
8736 json!({"name":"id","type":"string","nullable":false}),
8737 json!({"name":"project_id","type":"string","nullable":false}),
8738 json!({"name":"payload","type":"bytes","nullable":true}),
8739 ];
8740 if extra {
8741 columns.push(json!({"name":"later","type":"string","nullable":true}));
8742 }
8743 json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
8744 "scopes":[{"pattern":"project:{project_id}"}]}]})
8745 };
8746 for (label, bump) in [
8747 ("same-version recreation", false),
8748 ("version-bump reset", true),
8749 ("version-only bump reset", true),
8750 ] {
8751 let path = std::env::temp_dir().join(format!(
8752 "syncular-legacy-reset-{}-{}.db",
8753 label.replace(' ', "-"),
8754 uuid::Uuid::new_v4()
8755 ));
8756 let v1 = schema(1, false);
8757 let open = |schema: &Value, path: &std::path::Path| {
8758 SyncClient::open_path(
8759 "legacy-reset".into(),
8760 schema,
8761 ClientLimits::default(),
8762 path.to_str().unwrap(),
8763 )
8764 .expect("open client")
8765 };
8766 let row = |id: &str| {
8767 Map::from_iter([
8768 ("id".to_owned(), json!(id)),
8769 ("project_id".to_owned(), json!("p1")),
8770 ("payload".to_owned(), json!({"$bytes": "0a"})),
8771 ])
8772 };
8773 let upsert = |values| {
8774 vec![Mutation::Upsert {
8775 table: "tasks".into(),
8776 values,
8777 base_version: None,
8778 }]
8779 };
8780 let mut client = open(&v1, &path);
8781 client.create_synced_tables().expect("create synced tables");
8782 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8783 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8784 drop(client);
8785
8786 let connection = Connection::open(&path).expect("raw connection");
8787 let planted = json!([{
8788 "op": "upsert",
8789 "table": "tasks",
8790 "rowId": "t1",
8791 "baseVersion": null,
8792 "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
8793 }]);
8794 connection
8795 .execute(
8796 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8797 rusqlite::params![planted.to_string(), stale],
8798 )
8799 .expect("plant legacy intent");
8800 drop(connection);
8801
8802 let target = if bump {
8803 schema(2, label == "version-bump reset")
8804 } else {
8805 v1.clone()
8806 };
8807 let reopened = open(&target, &path);
8808 let rejection = reopened
8809 .rejections()
8810 .iter()
8811 .find(|rejection| rejection.client_commit_id == stale)
8812 .unwrap_or_else(|| panic!("{label}: rejection"));
8813 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8814 assert_eq!(
8815 rejection
8816 .details
8817 .as_ref()
8818 .and_then(|details| details.reason.as_deref()),
8819 Some(INVALID_STORED_VALUES_REASON),
8820 "{label}"
8821 );
8822 assert!(rejection.operation.is_some(), "{label}");
8823 assert_eq!(
8824 reopened.pending_commit_ids(),
8825 vec![successor.clone()],
8826 "{label}"
8827 );
8828 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8833 if label == "version-bump reset" {
8834 assert_eq!(
8835 reopened
8836 .query("SELECT id FROM tasks ORDER BY id", &[])
8837 .unwrap(),
8838 Vec::<Map<String, Value>>::new(),
8839 "{label}"
8840 );
8841 let mut next = reopened;
8845 let revision = next.local_revision();
8846 let batches = next.drain_change_batches();
8847 assert!(
8848 batches.iter().any(|batch| batch.rejections_changed
8849 && batch.outcomes_changed
8850 && batch.tables.iter().any(|change| change.table == "tasks")
8851 && batch.revision == revision.to_string()),
8852 "{label}: {batches:?}"
8853 );
8854 next.mutate(upsert(Map::from_iter([
8855 ("id".to_owned(), json!("t3")),
8856 ("project_id".to_owned(), json!("p1")),
8857 ("payload".to_owned(), json!({"$bytes": "0b"})),
8858 ("later".to_owned(), json!("v2")),
8859 ])))
8860 .expect("v2 authoring after the reset");
8861 assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
8862 assert_eq!(next.pending_payloads().len(), 2, "{label}");
8863 drop(next);
8864 } else {
8865 assert_eq!(
8866 reopened
8867 .query("SELECT id FROM tasks ORDER BY id", &[])
8868 .unwrap(),
8869 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8870 "{label}"
8871 );
8872 drop(reopened);
8873 }
8874
8875 if !bump {
8878 let mut recreated = open(&v1, &path);
8879 let fresh = recreated
8880 .mutate(upsert(row("t3")))
8881 .expect("fresh commit while open");
8882 recreated.drain_change_batches();
8883 let planted_fresh = json!([{
8884 "op": "upsert",
8885 "table": "tasks",
8886 "rowId": "t3",
8887 "baseVersion": null,
8888 "values": {"id": "t3", "project_id": "p1",
8889 "payload": {"$bytes": "zz"}}
8890 }]);
8891 let connection = Connection::open(&path).expect("raw connection");
8892 let affected = connection
8893 .execute(
8894 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8895 rusqlite::params![planted_fresh.to_string(), fresh],
8896 )
8897 .expect("plant fresh legacy intent");
8898 assert_eq!(
8899 affected, 1,
8900 "{label}: the in-place plant must hit the queued commit"
8901 );
8902 drop(connection);
8903 let revision_before = recreated.local_revision();
8904 recreated
8905 .recreate_with_schema(&v1)
8906 .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
8907 assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
8908 let fresh_rejection = recreated
8909 .rejections()
8910 .iter()
8911 .find(|rejection| rejection.client_commit_id == fresh)
8912 .expect("the in-place recovery rejection");
8913 assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8914 assert_eq!(
8915 fresh_rejection
8916 .details
8917 .as_ref()
8918 .and_then(|details| details.reason.as_deref()),
8919 Some(INVALID_STORED_VALUES_REASON),
8920 "{label}"
8921 );
8922 assert!(
8923 matches!(
8924 recreated
8925 .commit_outcome(&fresh)
8926 .expect("outcome read")
8927 .map(|outcome| outcome.status),
8928 Some(CommitOutcomeStatus::Rejected)
8929 ),
8930 "{label}"
8931 );
8932 assert!(recreated.local_revision() > revision_before, "{label}");
8933 let revision = recreated.local_revision();
8934 let batches = recreated.drain_change_batches();
8935 assert_eq!(batches.len(), 1, "{label}: one recovery batch");
8936 assert!(
8937 batches.iter().any(|batch| batch.rejections_changed
8938 && batch.outcomes_changed
8939 && batch.tables.iter().any(|change| change.table == "tasks")
8940 && batch.revision == revision.to_string()),
8941 "{label}: {batches:?}"
8942 );
8943 assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
8944 assert_eq!(
8945 recreated
8946 .query("SELECT id FROM tasks ORDER BY id", &[])
8947 .unwrap(),
8948 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8949 "{label}: the recovered commit leaves no projection"
8950 );
8951 drop(recreated);
8952 }
8953 let _ = std::fs::remove_file(&path);
8954 }
8955 }
8956
8957 #[test]
8958 fn legacy_values_recover_over_a_synced_authoritative_row() {
8959 let path = std::env::temp_dir().join(format!(
8960 "syncular-authoritative-row-{}.db",
8961 uuid::Uuid::new_v4()
8962 ));
8963 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8964 {"name":"id","type":"string","nullable":false},
8965 {"name":"project_id","type":"string","nullable":false},
8966 {"name":"title","type":"string","nullable":false},
8967 {"name":"payload","type":"bytes","nullable":true}],
8968 "scopes":[{"pattern":"project:{project_id}"}]}]});
8969 let open = |path: &std::path::Path| {
8970 SyncClient::open_path(
8971 "authoritative-row".into(),
8972 &schema,
8973 ClientLimits::default(),
8974 path.to_str().unwrap(),
8975 )
8976 .expect("open client")
8977 };
8978 let row = |title: &str| {
8979 Map::from_iter([
8980 ("id".to_owned(), json!("t1")),
8981 ("project_id".to_owned(), json!("p1")),
8982 ("title".to_owned(), json!(title)),
8983 ("payload".to_owned(), json!({"$bytes": "0a"})),
8984 ])
8985 };
8986 let upsert = |values| {
8987 vec![Mutation::Upsert {
8988 table: "tasks".into(),
8989 values,
8990 base_version: None,
8991 }]
8992 };
8993 let mut client = open(&path);
8994 client.create_synced_tables().expect("create synced tables");
8995 client
8998 .conn
8999 .execute_batch(
9000 "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
9001 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
9002 )
9003 .expect("seed authoritative row");
9004 let older = client.mutate(upsert(row("older"))).expect("older");
9005 let newer = client.mutate(upsert(row("newer"))).expect("newer");
9006 let successor = client.mutate(upsert(row("successor"))).expect("successor");
9007 drop(client);
9008
9009 let connection = Connection::open(&path).expect("raw connection");
9010 for commit_id in [&older, &newer] {
9011 let planted = json!([{
9012 "op": "upsert",
9013 "table": "tasks",
9014 "rowId": "t1",
9015 "baseVersion": null,
9016 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9017 "payload": {"$bytes": "zz"}}
9018 }]);
9019 connection
9020 .execute(
9021 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9022 rusqlite::params![planted.to_string(), commit_id],
9023 )
9024 .expect("plant legacy intent");
9025 }
9026 drop(connection);
9027
9028 let reopened = open(&path);
9029 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
9030 assert_eq!(
9031 reopened
9032 .rejections()
9033 .iter()
9034 .map(|rejection| rejection.client_commit_id.clone())
9035 .collect::<Vec<_>>(),
9036 vec![older.clone(), newer.clone()]
9037 );
9038 assert_eq!(
9041 reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9042 Ok(vec![Map::from_iter([
9043 ("id".to_owned(), json!("t1")),
9044 ("title".to_owned(), json!("successor")),
9045 ("_syncular_version".to_owned(), json!(1)),
9046 ])])
9047 );
9048
9049 drop(reopened);
9052 let connection = Connection::open(&path).expect("raw connection");
9053 connection
9054 .execute(
9055 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9056 rusqlite::params![
9057 json!([{
9058 "op": "upsert",
9059 "table": "tasks",
9060 "rowId": "t1",
9061 "baseVersion": null,
9062 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9063 "payload": {"$bytes": "zz"}}
9064 }])
9065 .to_string(),
9066 successor
9067 ],
9068 )
9069 .expect("plant successor intent");
9070 drop(connection);
9071 let late = open(&path);
9072 assert!(late.pending_commit_ids().is_empty());
9073 assert_eq!(
9074 late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9075 Ok(vec![Map::from_iter([
9076 ("id".to_owned(), json!("t1")),
9077 ("title".to_owned(), json!("authoritative")),
9078 ("_syncular_version".to_owned(), json!(1)),
9079 ])])
9080 );
9081 drop(late);
9082 let _ = std::fs::remove_file(&path);
9083 }
9084
9085 #[test]
9086 fn a_failed_value_recovery_commit_restores_every_side_effect() {
9087 let path = std::env::temp_dir().join(format!(
9088 "syncular-value-recovery-commit-{}.db",
9089 uuid::Uuid::new_v4()
9090 ));
9091 let path_str = path.to_str().expect("path");
9092 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9093 {"name":"id","type":"string","nullable":false},
9094 {"name":"project_id","type":"string","nullable":false},
9095 {"name":"payload","type":"bytes","nullable":true}],
9096 "scopes":[{"pattern":"project:{project_id}"}]}]});
9097 let conn = Connection::open(path_str).expect("replica connection");
9100 let mut client = SyncClient::with_connection(
9101 "value-recovery-commit".into(),
9102 &schema,
9103 ClientLimits::default(),
9104 conn,
9105 )
9106 .expect("replica");
9107 assert_eq!(
9108 client
9109 .conn
9110 .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
9111 .expect("journal mode"),
9112 "delete"
9113 );
9114 client.create_synced_tables().expect("create synced tables");
9115 let stale = client
9116 .mutate(vec![Mutation::Upsert {
9117 table: "tasks".into(),
9118 values: Map::from_iter([
9119 ("id".to_owned(), json!("t1")),
9120 ("project_id".to_owned(), json!("p1")),
9121 ("payload".to_owned(), json!({"$bytes": "0a"})),
9122 ]),
9123 base_version: None,
9124 }])
9125 .expect("queued write");
9126 client.drain_change_batches();
9127 let affected = client
9128 .conn
9129 .execute(
9130 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9131 rusqlite::params![
9132 json!([{
9133 "op": "upsert",
9134 "table": "tasks",
9135 "rowId": "t1",
9136 "baseVersion": null,
9137 "values": {"id": "t1", "project_id": "p1",
9138 "payload": {"$bytes": "zz"}}
9139 }])
9140 .to_string(),
9141 stale
9142 ],
9143 )
9144 .expect("plant legacy intent");
9145 assert_eq!(affected, 1, "the planted row must exist");
9146 let revision = client.local_revision();
9147 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
9148 let prior_outbox = client.pending_commit_ids();
9149 let prior_rejections = client.rejections().len();
9150 let prior_changes = client.change_queue.len();
9151 let prior_intents = client.sync_intent_queue.len();
9152 let prior_failed = client.failed_commits.len();
9153 let reader = Connection::open(path_str).expect("reader");
9156 reader
9157 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9158 .expect("reader snapshot");
9159 client
9160 .conn
9161 .busy_timeout(std::time::Duration::ZERO)
9162 .expect("busy timeout");
9163 let error = client
9164 .recreate_with_schema(&schema)
9165 .expect_err("a blocked COMMIT must refuse the recovery");
9166 assert!(error.starts_with("client.storage_busy:"), "{error}");
9167 assert!(client.conn.is_autocommit(), "the transaction is released");
9168 assert_eq!(client.local_revision(), revision);
9169 assert_eq!(client.pending_commit_ids(), prior_outbox);
9170 assert_eq!(client.rejections().len(), prior_rejections);
9171 assert_eq!(client.change_queue.len(), prior_changes);
9172 assert_eq!(client.sync_intent_queue.len(), prior_intents);
9173 assert_eq!(client.failed_commits.len(), prior_failed);
9174 assert_eq!(
9175 client.query("SELECT * FROM tasks", &[]).expect("rows"),
9176 rows
9177 );
9178 assert!(
9179 client
9180 .commit_outcome(&stale)
9181 .expect("outcome read")
9182 .is_none(),
9183 "the rolled-back recovery kept no outcome"
9184 );
9185 reader.execute_batch("ROLLBACK").expect("release reader");
9187 client
9188 .recreate_with_schema(&schema)
9189 .expect("retry after the blocked commit");
9190 assert!(client.pending_commit_ids().is_empty());
9191 assert_eq!(client.rejections().len(), prior_rejections + 1);
9192 assert!(client.local_revision() > revision);
9193 let batches = client.drain_change_batches();
9194 assert!(
9195 batches.iter().any(|batch| batch.rejections_changed
9196 && batch.outcomes_changed
9197 && batch.tables.iter().any(|change| change.table == "tasks")),
9198 "{batches:?}"
9199 );
9200 drop(client);
9201 let _ = std::fs::remove_file(&path);
9202 }
9203
9204 #[cfg(feature = "e2ee")]
9205 #[test]
9206 fn a_failed_recovery_restores_retained_failed_commits() {
9207 let path = std::env::temp_dir().join(format!(
9208 "syncular-value-recovery-retention-{}.db",
9209 uuid::Uuid::new_v4()
9210 ));
9211 let path_str = path.to_str().expect("path");
9212 let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
9213 {"name":"id","type":"string","nullable":false},
9214 {"name":"project_id","type":"string","nullable":false},
9215 {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
9216 "scopes":[{"pattern":"project:{project_id}"}]}]});
9217 let conn = Connection::open(path_str).expect("replica connection");
9218 let mut client = SyncClient::with_connection(
9219 "value-recovery-retention".into(),
9220 &schema,
9221 ClientLimits::default(),
9222 conn,
9223 )
9224 .expect("replica");
9225 client.create_synced_tables().expect("create synced tables");
9226 client.set_retain_failed_commits(true).expect("retention");
9227 client.set_encryption(crate::values::EncryptionConfig::default());
9229 client
9230 .mutate(vec![Mutation::Upsert {
9231 table: "secrets".into(),
9232 values: Map::from_iter([
9233 ("id".to_owned(), json!("s1")),
9234 ("project_id".to_owned(), json!("p1")),
9235 ("note".to_owned(), json!("secret")),
9236 ]),
9237 base_version: None,
9238 }])
9239 .expect("queued write");
9240 client.drain_change_batches();
9241 let revision = client.local_revision();
9242 let prior_outbox = client.pending_commit_ids();
9243 let prior_rejections = client.rejections().len();
9244 let prior_failed = client.failed_commits.len();
9245 let reader = Connection::open(path_str).expect("reader");
9246 reader
9247 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9248 .expect("reader snapshot");
9249 client
9250 .conn
9251 .busy_timeout(std::time::Duration::ZERO)
9252 .expect("busy timeout");
9253 let error = client
9254 .drop_unencodable_outbox(true, None)
9255 .expect_err("a blocked COMMIT must refuse the recovery");
9256 assert!(error.starts_with("client.storage_busy:"), "{error}");
9257 assert_eq!(client.failed_commits.len(), prior_failed);
9258 assert_eq!(client.pending_commit_ids(), prior_outbox);
9259 assert_eq!(client.rejections().len(), prior_rejections);
9260 assert_eq!(client.local_revision(), revision);
9261 reader.execute_batch("ROLLBACK").expect("release reader");
9263 client
9264 .drop_unencodable_outbox(true, None)
9265 .expect("retry after the blocked commit");
9266 assert_eq!(client.failed_commits.len(), prior_failed + 1);
9267 assert!(client.pending_commit_ids().is_empty());
9268 assert_eq!(client.rejections().len(), prior_rejections + 1);
9269 drop(client);
9270 let _ = std::fs::remove_file(&path);
9271 }
9272
9273 #[test]
9274 fn client_error_maps_legacy_codes_and_static_messages() {
9275 let code_only = ClientError::from("sync.unknown_table".to_owned());
9276 assert_eq!(code_only.code, "sync.unknown_table");
9277 assert_eq!(code_only.message, "the commit targets an unknown table");
9278 assert!(code_only.details.is_none());
9279
9280 let dynamic = ClientError::from(
9281 "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
9282 );
9283 assert_eq!(dynamic.code, "sync.invalid_request");
9284 assert_eq!(dynamic.message, "the authoring request is invalid");
9285 assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
9286 .as_str()
9287 .unwrap()
9288 .contains("patch cannot write scope column"));
9289
9290 let unclassified =
9291 ClientError::from("a commit must contain at least one operation".to_owned());
9292 assert_eq!(unclassified.code, "client.failed");
9293 assert_eq!(unclassified.message, "the local authoring operation failed");
9294 assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
9295 .as_str()
9296 .unwrap()
9297 .contains("at least one operation"));
9298
9299 let metadata = ClientError::from(QueryReadFailure {
9301 code: None,
9302 sqlite_code: Some(1),
9303 sqlite_message: Some("generic SQLite prose".to_owned()),
9304 rollback_failure: Some(Box::new(QueryReadFailure::from(
9305 rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
9306 ))),
9307 message: "unclassified read".to_owned(),
9308 });
9309 assert_eq!(metadata.code, "client.failed");
9310 assert_eq!(metadata.message, "the local authoring operation failed");
9311 let details = metadata.details.as_ref().unwrap();
9312 assert_eq!(details["sqliteCode"], 1);
9313 assert_eq!(details["sqliteMessage"], "generic SQLite prose");
9314 assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
9315 assert_eq!(details["legacyCause"], "unclassified read");
9316 }
9317
9318 #[test]
9319 fn schema_marker_read_keeps_storage_classification() {
9320 let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9321 rusqlite::ffi::Error::new(5),
9322 Some("database is locked".to_owned()),
9323 ));
9324 assert!(busy.starts_with("client.storage_busy:"), "{busy}");
9325 let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9326 rusqlite::ffi::Error::new(1),
9327 Some("unreadable".to_owned()),
9328 ));
9329 assert_eq!(
9330 invalid,
9331 "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
9332 );
9333
9334 let path =
9337 std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
9338 let setup = Connection::open(&path).expect("setup connection");
9339 setup
9340 .execute_batch(
9341 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
9342 )
9343 .unwrap();
9344 let locker = Connection::open(&path).expect("locker connection");
9345 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9346 let reader = Connection::open(&path).expect("reader connection");
9347 reader.busy_timeout(std::time::Duration::ZERO).unwrap();
9348 let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
9349 assert!(locked.starts_with("client.storage_busy:"), "{locked}");
9350 locker.execute_batch("ROLLBACK").unwrap();
9351 assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
9352 let rows = setup
9353 .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
9354 row.get::<_, i64>(0)
9355 })
9356 .unwrap();
9357 assert_eq!(rows, 1, "the guard performed no writes");
9358 drop(reader);
9359 drop(locker);
9360 drop(setup);
9361 let _ = std::fs::remove_file(&path);
9362 }
9363
9364 #[cfg(feature = "crdt-yjs")]
9365 #[test]
9366 fn crdt_authoring_pre_read_preserves_storage_classification() {
9367 let path =
9368 std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
9369 let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
9370 {"name":"id","type":"string","nullable":false},
9371 {"name":"project_id","type":"string","nullable":false},
9372 {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
9373 "scopes":[{"pattern":"project:{project_id}"}]}]});
9374 let mut client = SyncClient::open_path(
9375 "crdt-busy".into(),
9376 &schema,
9377 ClientLimits::default(),
9378 path.to_str().unwrap(),
9379 )
9380 .expect("open client");
9381 client.create_synced_tables().expect("create synced tables");
9382 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
9383
9384 let locker = Connection::open(&path).expect("locker connection");
9385 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9386 let busy = client
9387 .crdt_apply_update("notes", "n1", "doc", &[])
9388 .expect_err("busy crdt pre-read");
9389 assert_eq!(busy.code, "client.storage_busy");
9390 assert!(busy.retryable);
9391 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
9392 assert!(client.pending_commit_ids().is_empty());
9393 assert_eq!(client.local_revision(), 0);
9394
9395 locker.execute_batch("ROLLBACK").unwrap();
9396 drop(locker);
9397 drop(client);
9398 let _ = std::fs::remove_file(&path);
9399 }
9400
9401 #[test]
9402 fn large_unique_import_copies_no_unrelated_tables() {
9403 let (mut client, image, table) = unique_import_fixture(10240);
9404 let pending = client.pending_commit_ids();
9405 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
9406 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
9407 assert!(matches!(
9408 client.apply_sqlite_image(
9409 &image,
9410 &table,
9411 true,
9412 &[("project_id".into(), vec!["p1".into()])],
9413 10240,
9414 7,
9415 "digest"
9416 ),
9417 Ok(10240)
9418 ));
9419 assert_eq!(client.overlay_rebuild_count.get(), 0);
9420 assert_eq!(client.pending_commit_ids(), pending);
9421 assert_eq!(
9422 client
9423 .query("SELECT count(*) AS n FROM tasks", &[])
9424 .unwrap()[0]["n"],
9425 10241
9426 );
9427 assert_eq!(
9428 client
9429 .query("SELECT count(*) AS n FROM tasks_fts", &[])
9430 .unwrap()[0]["n"],
9431 10241
9432 );
9433 assert_eq!(
9434 client
9435 .query("SELECT count(*) AS n FROM notes_fts", &[])
9436 .unwrap()[0]["n"],
9437 8192
9438 );
9439 }
9440
9441 #[test]
9442 #[ignore = "manual scaling benchmark; see bench/README.md"]
9443 fn benchmark_unique_image_import() {
9444 let mut samples = Vec::new();
9445 for rows in [10240, 51200, 102400] {
9446 for trial in 0..4 {
9447 let (mut client, image, table) = unique_import_fixture(rows);
9448 let start = std::time::Instant::now();
9449 assert!(
9450 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)
9451 );
9452 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
9453 assert_eq!(
9454 client
9455 .query("SELECT count(*) AS n FROM tasks", &[])
9456 .unwrap()[0]["n"],
9457 rows + 1
9458 );
9459 assert_eq!(
9460 client
9461 .query("SELECT count(*) AS n FROM tasks_fts", &[])
9462 .unwrap()[0]["n"],
9463 rows + 1
9464 );
9465 assert_eq!(
9466 client
9467 .query("SELECT count(*) AS n FROM notes_fts", &[])
9468 .unwrap()[0]["n"],
9469 8192
9470 );
9471 assert_eq!(client.pending_commit_ids().len(), 1);
9472 if trial > 0 {
9473 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
9474 }
9475 }
9476 }
9477 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});
9478 let output = serde_json::to_string_pretty(&artifact).unwrap();
9479 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
9480 use std::io::Write;
9481 let mut file = std::fs::OpenOptions::new()
9482 .write(true)
9483 .create_new(true)
9484 .open(path)
9485 .unwrap();
9486 file.write_all(output.as_bytes()).unwrap();
9487 } else {
9488 println!("{output}");
9489 }
9490 }
9491
9492 #[test]
9493 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
9494 let schema = json!({
9495 "version": 1,
9496 "tables": [{
9497 "name": "catalogue_codes",
9498 "primaryKey": "id",
9499 "columns": [
9500 { "name": "id", "type": "string", "nullable": false },
9501 { "name": "release_id", "type": "string", "nullable": false },
9502 { "name": "code", "type": "string", "nullable": false },
9503 { "name": "title", "type": "string", "nullable": false }
9504 ],
9505 "scopes": [{ "pattern": "release:{release_id}" }],
9506 "ftsIndexes": [{
9507 "name": "catalogue_codes_fts",
9508 "columns": ["code", "title"],
9509 "tokenize": "unicode61 remove_diacritics 2"
9510 }]
9511 }]
9512 });
9513 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
9514 .expect("FTS5 client");
9515 let insert_trigger: String = client
9516 .conn
9517 .query_row(
9518 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
9519 [],
9520 |row| row.get(0),
9521 )
9522 .expect("insert trigger");
9523 let replace_guard: String = client
9524 .conn
9525 .query_row(
9526 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
9527 [],
9528 |row| row.get(0),
9529 )
9530 .expect("replace guard");
9531 assert!(!insert_trigger.contains("DELETE FROM"));
9532 assert!(replace_guard.contains("BEFORE INSERT"));
9533 assert!(replace_guard.contains("WHEN EXISTS"));
9534 let search = |client: &SyncClient, query: &str| {
9535 client
9536 .query(
9537 "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",
9538 &[Value::from(query)],
9539 )
9540 .expect("FTS query")
9541 };
9542
9543 client
9544 .mutate(vec![Mutation::Upsert {
9545 table: "catalogue_codes".to_owned(),
9546 values: Map::from_iter([
9547 ("id".to_owned(), Value::from("c1")),
9548 ("release_id".to_owned(), Value::from("r1")),
9549 ("code".to_owned(), Value::from("A01")),
9550 ("title".to_owned(), Value::from("Cholera")),
9551 ]),
9552 base_version: None,
9553 }])
9554 .expect("insert code");
9555 assert_eq!(search(&client, "cholera").len(), 1);
9556 let prior = client
9557 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9558 .unwrap();
9559 client
9560 .conn
9561 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
9562 .unwrap();
9563 client.create_synced_tables().unwrap();
9564 assert_eq!(
9565 client
9566 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9567 .unwrap(),
9568 prior
9569 );
9570 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();
9571 let rendered = serde_json::to_string(&plan).unwrap();
9572 assert!(rendered.contains("COVERING INDEX"));
9573 assert!(rendered.contains("INDEX 0:="));
9574 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
9575 assert_eq!(search(&client, "cholera").len(), 1);
9576
9577 client
9578 .mutate(vec![Mutation::Upsert {
9579 table: "catalogue_codes".to_owned(),
9580 values: Map::from_iter([
9581 ("id".to_owned(), Value::from("c1")),
9582 ("release_id".to_owned(), Value::from("r1")),
9583 ("code".to_owned(), Value::from("A01")),
9584 ("title".to_owned(), Value::from("Enteric infection")),
9585 ]),
9586 base_version: None,
9587 }])
9588 .expect("update code");
9589 assert!(search(&client, "cholera").is_empty());
9590 assert_eq!(search(&client, "enteric").len(), 1);
9591
9592 client
9593 .mutate(vec![Mutation::Delete {
9594 table: "catalogue_codes".to_owned(),
9595 row_id: "c1".to_owned(),
9596 base_version: None,
9597 }])
9598 .expect("delete code");
9599 assert!(search(&client, "enteric").is_empty());
9600 }
9601
9602 #[test]
9603 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
9604 let schema = json!({
9605 "version": 1,
9606 "tables": [{
9607 "name": "patient_notes",
9608 "primaryKey": "id",
9609 "columns": [
9610 { "name": "id", "type": "string", "nullable": false },
9611 { "name": "practice_id", "type": "string", "nullable": false },
9612 { "name": "encryption_key_id", "type": "string", "nullable": false },
9613 { "name": "title", "type": "string", "nullable": false }
9614 ],
9615 "scopes": [{ "pattern": "practice:{practice_id}" }],
9616 "ftsIndexes": [{
9617 "name": "patient_notes_fts",
9618 "columns": ["title"],
9619 "tokenize": "unicode61 remove_diacritics 2"
9620 }]
9621 }]
9622 });
9623 let mut client = SyncClient::new(
9624 "local-purge-test".to_owned(),
9625 &schema,
9626 ClientLimits::default(),
9627 )
9628 .expect("local purge client");
9629 client
9630 .conn
9631 .execute(
9632 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
9633 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
9634 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
9635 [],
9636 )
9637 .expect("seed base rows");
9638 client.overlay_dirty.set(true);
9639 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
9640
9641 let note = |id: &str, key_id: &str, title: &str| {
9642 Map::from_iter([
9643 ("id".to_owned(), Value::from(id)),
9644 ("practice_id".to_owned(), Value::from("practice-1")),
9645 ("encryption_key_id".to_owned(), Value::from(key_id)),
9646 ("title".to_owned(), Value::from(title)),
9647 ])
9648 };
9649 let doomed = client
9650 .mutate(vec![
9651 Mutation::Upsert {
9652 table: "patient_notes".to_owned(),
9653 values: note("target", "key-revoked", "Target changed"),
9654 base_version: None,
9655 },
9656 Mutation::Upsert {
9657 table: "patient_notes".to_owned(),
9658 values: note("unrelated", "key-held", "Unrelated changed"),
9659 base_version: None,
9660 },
9661 ])
9662 .expect("doomed commit");
9663 let kept = client
9664 .mutate(vec![Mutation::Upsert {
9665 table: "patient_notes".to_owned(),
9666 values: note("kept", "key-held", "Kept optimistic"),
9667 base_version: None,
9668 }])
9669 .expect("kept commit");
9670 client.drain_change_batches();
9671
9672 let input = LocalDataPurgeInput {
9673 purge_id: "purge-001".to_owned(),
9674 targets: vec![LocalDataPurgeTarget {
9675 table: "patient_notes".to_owned(),
9676 selectors: BTreeMap::from([(
9677 "encryption_key_id".to_owned(),
9678 vec!["key-revoked".to_owned()],
9679 )]),
9680 }],
9681 };
9682 assert_eq!(
9683 client.purge_local_data(&input).expect("apply purge"),
9684 LocalDataPurgeResult {
9685 already_applied: false,
9686 purged_rows: 1,
9687 dropped_commits: 1,
9688 }
9689 );
9690 let rows = client
9691 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
9692 .expect("visible rows");
9693 assert_eq!(rows.len(), 2);
9694 assert_eq!(rows[0]["id"], "kept");
9695 assert_eq!(rows[1]["id"], "unrelated");
9696 assert_eq!(rows[1]["title"], "Unrelated original");
9697 let fts = client
9698 .query(
9699 "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'",
9700 &[],
9701 )
9702 .expect("fts query");
9703 assert!(fts.is_empty());
9704 assert_eq!(client.outbox.len(), 1);
9705 assert_eq!(client.outbox[0].client_commit_id, kept);
9706 let outcome = client
9707 .commit_outcome(&doomed)
9708 .expect("read doomed outcome")
9709 .expect("doomed outcome");
9710 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
9711 match &outcome.results[0] {
9712 CommitOperationOutcome::Error { rejection } => {
9713 assert_eq!(rejection.code, "client.local_data_purged");
9714 assert_eq!(rejection.client_commit_id, doomed);
9715 }
9716 other => panic!("expected local purge rejection, got {other:?}"),
9717 }
9718 assert_eq!(client.drain_change_batches().len(), 1);
9719 assert_eq!(
9720 client.purge_local_data(&input).expect("retry purge"),
9721 LocalDataPurgeResult {
9722 already_applied: true,
9723 purged_rows: 0,
9724 dropped_commits: 0,
9725 }
9726 );
9727 let conflicting = LocalDataPurgeInput {
9728 purge_id: input.purge_id.clone(),
9729 targets: vec![LocalDataPurgeTarget {
9730 table: "patient_notes".to_owned(),
9731 selectors: BTreeMap::from([(
9732 "encryption_key_id".to_owned(),
9733 vec!["key-held".to_owned()],
9734 )]),
9735 }],
9736 };
9737 assert!(client
9738 .purge_local_data(&conflicting)
9739 .expect_err("id collision must fail")
9740 .contains("already used with a different plan"));
9741 }
9742}
9743
9744impl SubState {
9745 fn name(self) -> &'static str {
9746 match self {
9747 SubState::Active => "active",
9748 SubState::Revoked => "revoked",
9749 SubState::Failed => "failed",
9750 }
9751 }
9752
9753 fn parse(value: &str) -> Self {
9754 match value {
9755 "revoked" => Self::Revoked,
9756 "failed" => Self::Failed,
9757 _ => Self::Active,
9758 }
9759 }
9760}
9761
9762#[derive(Debug, Clone)]
9763struct Subscription {
9764 id: String,
9765 table: String,
9766 requested: Vec<(String, Vec<String>)>,
9767 params: Option<String>,
9768 cursor: i64,
9769 bootstrap_state: Option<String>,
9771 state: SubState,
9772 reason_code: Option<String>,
9773 effective: Option<Vec<(String, Vec<String>)>>,
9776 synced_once: bool,
9777}
9778
9779#[cfg(test)]
9780mod subscription_rearm_tests {
9781 use super::*;
9782 use serde_json::json;
9783
9784 fn client() -> SyncClient {
9785 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9786 {"name":"id","type":"string","nullable":false},
9787 {"name":"project_id","type":"string","nullable":false}],
9788 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[],"ftsIndexes":[]}]});
9789 SyncClient::new("rearm".into(), &schema, ClientLimits::default()).expect("client")
9790 }
9791
9792 fn scopes() -> Vec<(String, Vec<String>)> {
9793 vec![("project_id".to_owned(), vec!["p1".to_owned()])]
9794 }
9795
9796 fn store_revoked(client: &mut SyncClient) {
9798 client
9799 .subscribe("sub".into(), "tasks".into(), scopes(), None)
9800 .expect("subscribe");
9801 let sub = &mut client.subs[0];
9802 sub.cursor = 41;
9803 sub.bootstrap_state = Some("resume".to_owned());
9804 sub.effective = Some(scopes());
9805 sub.state = SubState::Revoked;
9806 sub.reason_code = Some("sync.scope_revoked".to_owned());
9807 client.persist_sub(&client.subs[0]).expect("persist");
9808 client.sync_needed = false;
9809 client.sync_intent_queue.clear();
9810 }
9811
9812 #[test]
9813 fn resubscribing_the_same_intent_rearms_a_revoked_registration() {
9814 let mut client = client();
9815 store_revoked(&mut client);
9816
9817 client
9818 .subscribe("sub".into(), "tasks".into(), scopes(), None)
9819 .expect("re-arm");
9820
9821 let view = client.subscription_state("sub").expect("state");
9822 assert_eq!(view.status, "active");
9823 assert_eq!(view.cursor, -1);
9824 assert!(!view.has_resume_token);
9825 assert_eq!(view.effective_scopes, None);
9826 assert_eq!(view.reason_code, None);
9827 assert!(client.sync_needed, "a re-arm asks for a pull");
9828 assert!(matches!(
9829 client.drain_sync_intents().as_slice(),
9830 [SyncIntent::Interactive]
9831 ));
9832 let stored: String = client
9833 .conn
9834 .query_row(
9835 "SELECT state_json FROM _syncular_subscriptions WHERE id = 'sub'",
9836 [],
9837 |row| row.get(0),
9838 )
9839 .expect("persisted row");
9840 let stored: Value = serde_json::from_str(&stored).expect("stored json");
9841 assert_eq!(stored["status"], json!("active"));
9842 assert_eq!(stored["cursor"], json!(-1));
9843 assert_eq!(stored["bootstrapState"], Value::Null);
9844 assert_eq!(stored["effectiveScopes"], Value::Null);
9845 assert_eq!(stored["reasonCode"], Value::Null);
9846 }
9847
9848 #[test]
9849 fn a_failed_registration_is_not_rearmed() {
9850 let mut client = client();
9851 client
9852 .subscribe("sub".into(), "tasks".into(), scopes(), None)
9853 .expect("subscribe");
9854 client.subs[0].state = SubState::Failed;
9855 client.subs[0].reason_code = Some("sync.scope_revoked".to_owned());
9856 client.persist_sub(&client.subs[0]).expect("persist");
9857 client.sync_needed = false;
9858 client.sync_intent_queue.clear();
9859
9860 client
9861 .subscribe("sub".into(), "tasks".into(), scopes(), None)
9862 .expect("subscribe");
9863
9864 let view = client.subscription_state("sub").expect("state");
9865 assert_eq!(view.status, "failed");
9866 assert_eq!(view.reason_code.as_deref(), Some("sync.scope_revoked"));
9867 assert!(!client.sync_needed);
9868 assert!(client.drain_sync_intents().is_empty());
9869 }
9870}
9871
9872#[derive(Debug, Clone)]
9873struct OutboxOp {
9874 upsert: bool,
9875 table: String,
9876 row_id: String,
9877 base_version: Option<i64>,
9878 values: Option<Map<String, Value>>,
9882}
9883
9884impl From<&OutboxOp> for CommitOperation {
9885 fn from(operation: &OutboxOp) -> Self {
9886 Self {
9887 table: operation.table.clone(),
9888 row_id: operation.row_id.clone(),
9889 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
9890 base_version: operation.base_version,
9891 values: operation.values.clone(),
9892 }
9893 }
9894}
9895
9896#[derive(Debug, Clone)]
9897struct OutboxCommit {
9898 client_commit_id: String,
9899 ops: Vec<OutboxOp>,
9900}
9901
9902fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
9906 commit
9907 .ops
9908 .iter()
9909 .filter(|operation| operation.upsert)
9910 .map(|operation| {
9911 (
9912 operation.table.as_str(),
9913 operation
9914 .values
9915 .as_ref()
9916 .map(|values| values.keys().map(String::as_str).collect())
9917 .unwrap_or_default(),
9918 )
9919 })
9920 .collect()
9921}
9922
9923type FailedCommit = (OutboxCommit, Map<String, Value>);
9924
9925#[derive(Debug, Clone)]
9926struct CompiledLocalDataPurgeTarget {
9927 table: String,
9928 selectors: Vec<(String, Vec<String>)>,
9929}
9930
9931struct StoredCommitOutcomeRow {
9932 sequence: i64,
9933 client_commit_id: String,
9934 status: String,
9935 recorded_at_ms: i64,
9936 results_json: String,
9937 operations_json: Option<String>,
9938 resolution: String,
9939 resolved_at_ms: Option<i64>,
9940 replacement_client_commit_id: Option<String>,
9941}
9942
9943impl StoredCommitOutcomeRow {
9944 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
9945 Ok(Self {
9946 sequence: row.get(0)?,
9947 client_commit_id: row.get(1)?,
9948 status: row.get(2)?,
9949 recorded_at_ms: row.get(3)?,
9950 results_json: row.get(4)?,
9951 operations_json: row.get(5)?,
9952 resolution: row.get(6)?,
9953 resolved_at_ms: row.get(7)?,
9954 replacement_client_commit_id: row.get(8)?,
9955 })
9956 }
9957}
9958
9959enum SectionError {
9962 FailClosed,
9963 Abort(String, String),
9964}
9965
9966#[derive(Debug)]
9967pub(crate) struct RequestMeta {
9968 pushed_ids: Vec<String>,
9969 fresh: Vec<(String, bool)>,
9972 pub(crate) accept: u8,
9973 deferred_commits: usize,
9977}
9978
9979#[derive(Default)]
9980struct ChangeAccumulator {
9981 tables: BTreeMap<String, Option<BTreeSet<String>>>,
9983 windows: BTreeMap<(String, String), BTreeSet<String>>,
9984 status: bool,
9985 conflicts: bool,
9986 rejections: bool,
9987 outcomes: bool,
9988}
9989
9990impl ChangeAccumulator {
9991 fn table(&mut self, table: &str) {
9992 self.tables.insert(table.to_owned(), None);
9993 }
9994
9995 fn scope(&mut self, table: &str, key: String) {
9996 match self.tables.get_mut(table) {
9997 Some(None) => {}
9998 Some(Some(keys)) => {
9999 keys.insert(key);
10000 }
10001 None => {
10002 self.tables
10003 .insert(table.to_owned(), Some(BTreeSet::from([key])));
10004 }
10005 }
10006 }
10007
10008 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
10009 self.windows
10010 .entry((base_key.to_owned(), table.to_owned()))
10011 .or_default()
10012 .insert(unit.to_owned());
10013 }
10014
10015 fn touched(&self) -> bool {
10016 !self.tables.is_empty()
10017 || !self.windows.is_empty()
10018 || self.status
10019 || self.conflicts
10020 || self.rejections
10021 || self.outcomes
10022 }
10023}
10024
10025const PUSH_OPS_PER_REQUEST: usize = 500;
10031const MAX_LOCAL_PURGE_TARGETS: usize = 64;
10032const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
10033const MAX_LOCAL_PURGE_VALUES: usize = 128;
10034const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
10035pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
10036
10037struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
10039
10040impl OverlayDirty {
10041 fn clean() -> Self {
10042 Self(RefCell::new(Some(BTreeSet::new())))
10043 }
10044 fn get(&self) -> bool {
10045 self.0
10046 .borrow()
10047 .as_ref()
10048 .is_none_or(|tables| !tables.is_empty())
10049 }
10050 fn set(&self, dirty: bool) {
10051 *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
10052 }
10053 fn table(&self, table: &str) {
10054 if let Some(tables) = self.0.borrow_mut().as_mut() {
10055 tables.insert(table.to_owned());
10056 }
10057 }
10058 fn snapshot(&self) -> Option<BTreeSet<String>> {
10059 self.0.borrow().clone()
10060 }
10061 fn restore(&self, tables: Option<BTreeSet<String>>) {
10062 *self.0.borrow_mut() = tables;
10063 }
10064}
10065
10066pub struct SyncClient {
10067 #[cfg(feature = "bench-internals")]
10068 benchmark_phases: Recorder,
10069 conn: Connection,
10070 schema: ClientSchema,
10071 client_id: String,
10072 limits: ClientLimits,
10073 subs: Vec<Subscription>,
10074 outbox: Vec<OutboxCommit>,
10075 failed_commits: Vec<FailedCommit>,
10076 retain_failed_commits: bool,
10077 conflicts: Vec<ConflictRecord>,
10078 rejections: Vec<RejectionRecord>,
10079 schema_floor: Option<SchemaFloor>,
10080 lease_state: Option<LeaseState>,
10082 stopped: bool,
10084 upgrading: bool,
10086 sync_needed: bool,
10088 realtime_policy: RealtimePolicy,
10091 realtime_state: RealtimeState,
10092 realtime_reason_code: Option<String>,
10093 realtime_retry_delay_ms: Option<u64>,
10094 presence: HashMap<String, HashMap<String, PresencePeer>>,
10096 now_ms: Option<i64>,
10099 encryption: crate::values::EncryptionConfig,
10103 security_preflight: bool,
10106 transport_enabled: bool,
10107 previous_version: Option<PreviousVersionContextConfig>,
10111 insert_sql: RefCell<HashMap<String, String>>,
10117 overlay_dirty: OverlayDirty,
10120 storage_failure: RefCell<Option<QueryReadFailure>>,
10122 #[cfg(test)]
10125 overlay_rebuild_count: Cell<usize>,
10126 #[cfg(test)]
10127 acknowledged_replay_count: Cell<usize>,
10128 #[cfg(test)]
10131 outcome_prune_count: Cell<usize>,
10132 progress: ProgressObserver,
10134 change_queue: VecDeque<ClientChangeBatch>,
10135 sync_intent_queue: VecDeque<SyncIntent>,
10136 retry_delay_ms: u64,
10138 round_retry_delay_ms: Option<u64>,
10140 round_failure: Option<String>,
10145 active_round: Option<uuid::Uuid>,
10146 last_round: Option<DiagnosticLastRound>,
10147 last_change: Option<DiagnosticLastChange>,
10148 query_failures: Vec<DiagnosticQueryFailure>,
10150 coalesced: Option<CoalescedApply>,
10152 coalesced_failure: Option<String>,
10155}
10156
10157struct CoalescedApply {
10161 rows: usize,
10162 subs: Vec<Subscription>,
10163 outbox: Vec<OutboxCommit>,
10164 failed_commits: Vec<FailedCommit>,
10165 conflicts: Vec<ConflictRecord>,
10166 rejections: Vec<RejectionRecord>,
10167 schema_floor: Option<SchemaFloor>,
10168 lease_state: Option<LeaseState>,
10169 stopped: bool,
10170 upgrading: bool,
10171 sync_needed: bool,
10172 overlay_dirty: Option<BTreeSet<String>>,
10173 change_queue: usize,
10174 sync_intent_queue: usize,
10175 last_change: Option<DiagnosticLastChange>,
10176}
10177
10178const COALESCED_APPLY_ROWS: usize = 1024;
10179
10180pub(crate) fn quote_ident(name: &str) -> String {
10181 format!("\"{}\"", name.replace('"', "\"\""))
10182}
10183pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
10187 conn.query_row(
10188 "SELECT value FROM _syncular_meta WHERE key = ?1",
10189 rusqlite::params![key],
10190 |row| row.get::<_, String>(0),
10191 )
10192 .ok()
10193}
10194
10195pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
10196 let _ = conn.execute(
10197 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
10198 rusqlite::params![key, value],
10199 );
10200}
10201
10202pub(crate) fn meta_delete(conn: &Connection, key: &str) {
10203 let _ = conn.execute(
10204 "DELETE FROM _syncular_meta WHERE key = ?1",
10205 rusqlite::params![key],
10206 );
10207}
10208
10209fn row_id_predicate(table: &TableSchema) -> String {
10211 let key = quote_ident(&table.primary_key);
10212 let text_match = format!("CAST({key} AS TEXT) = ?1");
10213 match table.columns[table.pk_index].ty {
10214 ColumnType::String | ColumnType::Json => {
10215 format!("{key} = ?1 AND {text_match}")
10216 }
10217 ColumnType::Integer | ColumnType::Boolean => {
10221 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
10222 }
10223 _ => text_match,
10226 }
10227}
10228
10229fn is_local_operation_code_like(value: &str) -> bool {
10230 let bytes = value.as_bytes();
10231 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
10232 && bytes
10233 .iter()
10234 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
10235}
10236
10237fn base_table(name: &str) -> String {
10238 quote_ident(&format!("_syncular_base_{name}"))
10239}
10240
10241type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
10246
10247fn window_base_key(base: &WindowBase) -> String {
10248 format!(
10249 "{}\0{}\0{}",
10250 base.table,
10251 base.variable,
10252 canonical_scope_json(&base.fixed_scopes)
10253 )
10254}
10255
10256fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
10258 let mut scopes = base.fixed_scopes.clone();
10259 scopes.retain(|(k, _)| k != &base.variable);
10260 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
10261 scopes
10262}
10263
10264fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
10268 let canonical = canonical_scope_json(&unit_scopes(base, unit));
10269 let digest = Sha256::digest(canonical.as_bytes());
10270 let hex = bytes_to_hex(&digest);
10271 format!("w:{}:{}", base.table, &hex[..16])
10272}
10273
10274fn visible_table(name: &str) -> String {
10275 quote_ident(name)
10276}
10277
10278const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
10279
10280fn is_synced_table_name(name: &str) -> bool {
10283 if name.starts_with("sqlite_") {
10284 return false;
10285 }
10286 if name.starts_with("_syncular_base_") {
10287 return true; }
10289 !name.starts_with("_syncular_")
10291}
10292
10293fn blob_id_for(bytes: &[u8]) -> String {
10295 let digest = Sha256::digest(bytes);
10296 format!("sha256:{}", bytes_to_hex(&digest))
10297}
10298
10299enum RowParam<'a> {
10303 Cell(&'a Option<ColumnValue>),
10304 Version(i64),
10305}
10306
10307impl rusqlite::ToSql for RowParam<'_> {
10308 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
10309 Ok(match self {
10310 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
10311 RowParam::Cell(cell) => match cell {
10312 None => ToSqlOutput::Owned(SqlValue::Null),
10313 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
10314 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
10315 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
10316 Some(ColumnValue::Boolean(b)) => {
10317 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
10318 }
10319 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
10320 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
10321 }
10322 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
10324 ToSqlOutput::Borrowed(ValueRef::Blob(b))
10325 }
10326 },
10327 })
10328 }
10329}
10330
10331fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
10333 match param.to_sql().map_err(|error| error.to_string())? {
10334 ToSqlOutput::Owned(value) => Ok(value),
10335 ToSqlOutput::Borrowed(value) => {
10336 SqlValue::try_from(value).map_err(|error| error.to_string())
10337 }
10338 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
10339 }
10340}
10341
10342fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
10350 use ssp2::segment::ColumnType;
10351 let mismatch = || {
10352 Err(format!(
10353 "image column {:?} holds a value of the wrong type",
10354 column.name
10355 ))
10356 };
10357 match value {
10358 ValueRef::Null => {
10359 if !column.nullable {
10360 return Err(format!(
10361 "image column {:?} is NULL but not nullable",
10362 column.name
10363 ));
10364 }
10365 Ok(ToSqlOutput::Owned(SqlValue::Null))
10366 }
10367 ValueRef::Integer(i) => match column.ty {
10368 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
10369 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
10370 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
10371 _ => mismatch(),
10372 },
10373 ValueRef::Real(_) => match column.ty {
10374 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
10375 _ => mismatch(),
10376 },
10377 ValueRef::Text(t) => {
10378 std::str::from_utf8(t)
10379 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
10380 match column.ty {
10381 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
10382 Ok(ToSqlOutput::Borrowed(value))
10383 }
10384 _ => mismatch(),
10385 }
10386 }
10387 ValueRef::Blob(_) => match column.ty {
10388 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
10390 _ => mismatch(),
10391 },
10392 }
10393}
10394
10395pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
10396 use rusqlite::types::ValueRef;
10397 match value {
10398 ValueRef::Null => Value::Null,
10399 ValueRef::Integer(i) => match column.ty {
10400 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
10401 ssp2::segment::ColumnType::Float => {
10402 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
10403 }
10404 _ => Value::from(i),
10405 },
10406 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
10407 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
10408 ValueRef::Blob(b) => {
10409 let mut map = Map::new();
10410 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
10411 Value::Object(map)
10412 }
10413 }
10414}
10415
10416fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
10420 Ok(match value {
10421 Value::Null => SqlValue::Null,
10422 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
10423 Value::Number(n) => {
10424 if let Some(i) = n.as_i64() {
10425 SqlValue::Integer(i)
10426 } else if let Some(f) = n.as_f64() {
10427 SqlValue::Real(f)
10428 } else {
10429 return Err(format!("query param number {n} is out of range"));
10430 }
10431 }
10432 Value::String(s) => SqlValue::Text(s.clone()),
10433 Value::Object(_) => {
10434 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
10435 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
10436 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
10437 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
10438 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
10439 })?)
10440 } else {
10441 return Err(
10442 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
10443 .to_owned(),
10444 );
10445 }
10446 }
10447 Value::Array(_) => return Err("query array params are not supported".to_owned()),
10448 })
10449}
10450
10451fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
10456 use rusqlite::types::ValueRef;
10457 match value {
10458 ValueRef::Null => Value::Null,
10459 ValueRef::Integer(i) => {
10460 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
10464 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
10465 Value::from(i)
10466 } else {
10467 let mut map = Map::new();
10468 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
10469 Value::Object(map)
10470 }
10471 }
10472 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
10473 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
10474 ValueRef::Blob(b) => {
10475 let mut map = Map::new();
10476 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
10477 Value::Object(map)
10478 }
10479 }
10480}
10481
10482fn query_connection(
10484 conn: &Connection,
10485 sql: &str,
10486 params: &[QueryValue],
10487) -> Result<Vec<QueryRow>, QueryReadFailure> {
10488 crate::query_guard::assert_read_only_query(sql)?;
10489 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
10490 let bound: Vec<SqlValue> = params
10491 .iter()
10492 .map(json_param_to_sql)
10493 .collect::<Result<_, _>>()?;
10494 let mut stmt = conn.prepare(&lowered_sql)?;
10495 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
10496 let bound_refs: Vec<&dyn rusqlite::ToSql> =
10497 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
10498 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
10499 let mut out = Vec::new();
10500 while let Some(row) = sql_rows.next()? {
10501 let mut record = Map::new();
10502 for (i, name) in column_names.iter().enumerate() {
10503 let value = row.get_ref(i)?;
10504 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
10505 }
10506 out.push(record);
10507 }
10508 Ok(out)
10509}
10510
10511fn persisted_window_state(
10512 conn: &Connection,
10513 base: &WindowBase,
10514) -> Result<WindowState, QueryReadFailure> {
10515 let mut stmt = conn.prepare(
10516 "SELECT windows.unit, subscriptions.state_json
10517 FROM _syncular_windows AS windows
10518 JOIN _syncular_subscriptions AS subscriptions
10519 ON subscriptions.id = windows.sub_id
10520 WHERE windows.base = ?1
10521 ORDER BY windows.unit ASC",
10522 )?;
10523 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
10524 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10525 })?;
10526 let mut units = Vec::new();
10527 let mut pending = Vec::new();
10528 for row in rows {
10529 let (unit, raw) = row?;
10530 let state: Value = serde_json::from_str(&raw)
10531 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
10532 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
10533 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
10534 || state
10535 .get("bootstrapState")
10536 .is_some_and(|value| !value.is_null());
10537 if is_pending {
10538 pending.push(unit.clone());
10539 }
10540 units.push(unit);
10541 }
10542 Ok(WindowState { units, pending })
10543}
10544
10545#[derive(Debug, Clone, Copy)]
10547pub struct SnapshotStatement<'a> {
10548 pub sql: &'a str,
10549 pub params: &'a [Value],
10550}
10551
10552#[derive(Debug, Clone, Default)]
10556pub struct SnapshotReadRequest<'a> {
10557 pub statements: Vec<SnapshotStatement<'a>>,
10558 pub coverage: &'a [WindowCoverage],
10559 pub subscriptions: Vec<String>,
10560 pub commit_ids: Vec<String>,
10561}
10562
10563#[cfg(test)]
10564thread_local! {
10565 static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
10569 std::cell::RefCell::new(None);
10570}
10571
10572#[cfg(test)]
10573fn run_snapshot_read_hook() {
10574 SNAPSHOT_READ_HOOK.with(|slot| {
10575 if let Some(hook) = slot.borrow_mut().take() {
10576 hook();
10577 }
10578 });
10579}
10580
10581fn snapshot_read_connection(
10586 conn: &Connection,
10587 request: &SnapshotReadRequest<'_>,
10588) -> Result<SnapshotRead, QueryReadFailure> {
10589 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
10590 let corrupt = |message: &str| QueryReadFailure {
10591 code: Some("sync.local_corrupt"),
10592 sqlite_code: None,
10593 sqlite_message: None,
10594 rollback_failure: None,
10595 message: message.to_owned(),
10596 };
10597 let stored_failure = |error: rusqlite::Error, message: &str| match error {
10598 rusqlite::Error::Utf8Error(..)
10599 | rusqlite::Error::InvalidColumnType(..)
10600 | rusqlite::Error::FromSqlConversionFailure(..)
10601 | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
10602 _ => QueryReadFailure::from(error),
10603 };
10604 let result: Result<SnapshotRead, QueryReadFailure> = (|| {
10605 let revision: Option<String> = conn
10608 .query_row(
10609 "SELECT value FROM _syncular_meta WHERE key = ?1",
10610 rusqlite::params![LOCAL_REVISION_KEY],
10611 |row| row.get(0),
10612 )
10613 .optional()
10614 .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
10615 let revision = match revision {
10616 None => "0".to_owned(),
10617 Some(value)
10618 if value == "0"
10619 || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
10620 && value.bytes().all(|b| b.is_ascii_digit())
10621 && value.parse::<u64>().is_ok()) =>
10622 {
10623 value
10624 }
10625 Some(_) => return Err(corrupt("persisted local revision is invalid")),
10626 };
10627 let mut queries = Vec::with_capacity(request.statements.len());
10628 for statement in &request.statements {
10629 queries.push(query_connection(conn, statement.sql, statement.params)?);
10630 #[cfg(test)]
10631 run_snapshot_read_hook();
10632 }
10633 let mut pending = Vec::new();
10634 let mut missing = Vec::new();
10635 for requested in request.coverage {
10636 let base_key = window_base_key(&requested.base);
10637 let state = persisted_window_state(conn, &requested.base)?;
10638 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
10639 let reference = WindowUnitRef {
10640 base_key: base_key.clone(),
10641 unit: unit.clone(),
10642 };
10643 if !state.units.iter().any(|held| held == &unit) {
10644 missing.push(reference);
10645 } else if state.pending.iter().any(|held| held == &unit) {
10646 pending.push(reference);
10647 }
10648 }
10649 }
10650 let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
10651 for id in &request.subscriptions {
10652 let row: Option<(String, String)> = conn
10653 .query_row(
10654 "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
10655 rusqlite::params![id],
10656 |row| Ok((row.get(0)?, row.get(1)?)),
10657 )
10658 .optional()
10659 .map_err(|error| {
10660 stored_failure(error, "persisted subscription state is invalid")
10661 })?;
10662 let Some((table, raw)) = row else {
10663 subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
10664 continue;
10665 };
10666 let state: Value = serde_json::from_str(&raw)
10669 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
10670 let status = match state.get("status").and_then(Value::as_str) {
10671 Some(value @ ("active" | "revoked" | "failed")) => value,
10672 _ => return Err(corrupt("persisted subscription state is invalid")),
10673 };
10674 let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
10675 return Err(corrupt("persisted subscription state is invalid"));
10676 };
10677 if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
10678 return Err(corrupt("persisted subscription state is invalid"));
10679 }
10680 let effective_scopes = state
10681 .get("effectiveScopes")
10682 .filter(|value| !value.is_null());
10683 if let Some(scopes) = effective_scopes {
10684 json_to_scope_map(scopes)
10685 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
10686 }
10687 let has_resume_token = match state.get("bootstrapState") {
10688 None | Some(Value::Null) => false,
10689 Some(Value::String(_)) => true,
10690 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
10691 };
10692 let reason_code = match state.get("reasonCode") {
10695 None | Some(Value::Null) => None,
10696 Some(Value::String(value)) => Some(value.clone()),
10697 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
10698 };
10699 let active = status == "active";
10700 subscriptions.push(SubscriptionCatchup::Known {
10701 id: id.clone(),
10702 table,
10703 status: status.to_owned(),
10704 cursor,
10705 has_resume_token,
10706 bootstrap_complete: active && cursor >= 0 && !has_resume_token,
10707 known_pending_pages: active && (has_resume_token || cursor < 0),
10708 effective_scopes: effective_scopes.cloned(),
10709 reason_code,
10710 });
10711 }
10712 let mut deliveries = Vec::with_capacity(request.commit_ids.len());
10713 for id in &request.commit_ids {
10714 let pending: bool = conn.query_row(
10715 "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
10716 rusqlite::params![id],
10717 |row| row.get(0),
10718 )?;
10719 if pending {
10720 deliveries.push(CommitDelivery::Pending {
10721 client_commit_id: id.clone(),
10722 });
10723 continue;
10724 }
10725 let row = conn
10726 .query_row(
10727 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
10728 resolution, resolved_at_ms, replacement_client_commit_id
10729 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
10730 rusqlite::params![id],
10731 StoredCommitOutcomeRow::from_row,
10732 )
10733 .optional()
10734 .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
10735 deliveries.push(match row {
10736 Some(row) => CommitDelivery::Known {
10737 client_commit_id: id.clone(),
10738 outcome: SyncClient::outcome_from_row(row)
10739 .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
10740 },
10741 None => CommitDelivery::Unknown {
10742 client_commit_id: id.clone(),
10743 },
10744 });
10745 }
10746 Ok(SnapshotRead {
10747 revision,
10748 queries,
10749 coverage: CoverageSnapshot {
10750 complete: pending.is_empty() && missing.is_empty(),
10751 pending,
10752 missing,
10753 },
10754 subscriptions,
10755 deliveries,
10756 })
10757 })();
10758 match result {
10759 Ok(read) => {
10760 conn.execute_batch("RELEASE syncular_snapshot_read")?;
10761 Ok(read)
10762 }
10763 Err(mut error) => {
10764 if let Err(rollback) = conn
10765 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
10766 {
10767 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
10768 }
10769 Err(error)
10770 }
10771 }
10772}
10773
10774fn snapshot_connection(
10775 conn: &Connection,
10776 sql: &str,
10777 params: &[Value],
10778 coverage: &[WindowCoverage],
10779) -> Result<QuerySnapshot, QueryReadFailure> {
10780 let read = snapshot_read_connection(
10781 conn,
10782 &SnapshotReadRequest {
10783 statements: vec![SnapshotStatement { sql, params }],
10784 coverage,
10785 subscriptions: Vec::new(),
10786 commit_ids: Vec::new(),
10787 },
10788 )?;
10789 Ok(QuerySnapshot {
10790 revision: read.revision,
10791 rows: read
10792 .queries
10793 .into_iter()
10794 .next()
10795 .expect("single-query snapshot returns one result"),
10796 coverage: read.coverage,
10797 })
10798}
10799
10800pub struct FileQuerySnapshotReader {
10805 path: String,
10806 conn: Option<Connection>,
10807}
10808
10809impl FileQuerySnapshotReader {
10810 #[must_use]
10811 pub fn new(path: impl Into<String>) -> Self {
10812 Self {
10813 path: path.into(),
10814 conn: None,
10815 }
10816 }
10817
10818 fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
10819 if self.conn.is_none() {
10820 let conn = Connection::open_with_flags(
10821 &self.path,
10822 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
10823 )
10824 .map_err(QueryReadFailure::from)?;
10825 conn.busy_timeout(std::time::Duration::from_millis(250))
10826 .map_err(QueryReadFailure::from)?;
10827 self.conn = Some(conn);
10828 }
10829 self.conn
10830 .as_ref()
10831 .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
10832 }
10833
10834 pub fn snapshot_read(
10837 &mut self,
10838 request: &SnapshotReadRequest<'_>,
10839 ) -> Result<SnapshotRead, QueryReadFailure> {
10840 snapshot_read_connection(self.connection()?, request)
10841 }
10842
10843 pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
10846 Ok(self
10847 .snapshot_read(&SnapshotReadRequest::default())?
10848 .revision)
10849 }
10850
10851 pub fn subscription_catchup(
10853 &mut self,
10854 id: &str,
10855 ) -> Result<SubscriptionCatchup, QueryReadFailure> {
10856 let read = self.snapshot_read(&SnapshotReadRequest {
10857 subscriptions: vec![id.to_owned()],
10858 ..Default::default()
10859 })?;
10860 Ok(read
10861 .subscriptions
10862 .into_iter()
10863 .next()
10864 .expect("one requested catch-up"))
10865 }
10866
10867 pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
10870 let read = self.snapshot_read(&SnapshotReadRequest {
10871 commit_ids: vec![id.to_owned()],
10872 ..Default::default()
10873 })?;
10874 Ok(read
10875 .deliveries
10876 .into_iter()
10877 .next()
10878 .expect("one requested delivery"))
10879 }
10880
10881 pub fn query_snapshot(
10884 &mut self,
10885 sql: &str,
10886 params: &[Value],
10887 coverage: &[WindowCoverage],
10888 ) -> Result<QuerySnapshot, QueryReadFailure> {
10889 snapshot_connection(self.connection()?, sql, params, coverage)
10890 }
10891}
10892
10893fn validate_authority_reads(
10894 schema: &ClientSchema,
10895 declarations: &[crate::api::AuthorityReadDeclaration],
10896) -> Result<(), String> {
10897 let mut seen = BTreeSet::new();
10898 for read in declarations {
10899 let table = schema
10900 .table(&read.table)
10901 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
10902 let plain = |name: &str| {
10903 table.columns.iter().enumerate().any(|(i, c)| {
10904 c.name == name
10905 && name != "authority_version"
10906 && !name.starts_with("_sync")
10907 && !table.encrypted_columns.iter().any(|e| e.index == i)
10908 && matches!(
10909 c.ty,
10910 ssp2::segment::ColumnType::String
10911 | ssp2::segment::ColumnType::Integer
10912 | ssp2::segment::ColumnType::Float
10913 | ssp2::segment::ColumnType::Boolean
10914 | ssp2::segment::ColumnType::Json
10915 )
10916 })
10917 };
10918 if !seen.insert(&read.table)
10919 || read.columns.is_empty()
10920 || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
10921 || !read.columns.contains(&table.primary_key)
10922 || !read.columns.iter().all(|c| plain(c))
10923 || read.scopes.is_empty()
10924 || !read.scopes.iter().all(|(v, values)| {
10925 table
10926 .scope_column(v)
10927 .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
10928 && !values.is_empty()
10929 && values.iter().all(|v| !v.is_empty() && v != "*")
10930 })
10931 {
10932 return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
10933 }
10934 }
10935 Ok(())
10936}
10937
10938fn schema_marker_read_failure(error: rusqlite::Error) -> String {
10943 let failure = QueryReadFailure::from(error);
10944 if failure.code.is_some() {
10945 failure.to_string()
10946 } else {
10947 "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
10948 }
10949}
10950
10951fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
10955 if limits.outcome_retention_max_entries == Some(0) {
10956 return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
10957 }
10958 if [
10959 limits.max_push_commits_per_request,
10960 limits.max_push_operations_per_request,
10961 limits.max_push_request_bytes,
10962 ]
10963 .into_iter()
10964 .flatten()
10965 .any(|value| value == 0 || value > u32::MAX as usize)
10966 {
10967 return Err(
10968 "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
10969 );
10970 }
10971 Ok(())
10972}
10973
10974fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
10975 let corrupt =
10976 || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
10977 let table_type: Option<String> = conn
10978 .query_row(
10979 "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
10980 [],
10981 |row| row.get(0),
10982 )
10983 .optional()
10984 .map_err(schema_marker_read_failure)?;
10985 let Some(table_type) = table_type else {
10986 return Ok(None);
10987 };
10988 if table_type != "table" {
10989 return Err(corrupt());
10990 }
10991 let mut statement = conn
10995 .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
10996 .map_err(schema_marker_read_failure)?;
10997 let mut rows = statement
10998 .query([LOCAL_SCHEMA_VERSION_KEY])
10999 .map_err(schema_marker_read_failure)?;
11000 let value: Option<String> = rows
11001 .next()
11002 .map_err(schema_marker_read_failure)?
11003 .map(|row| row.get(0))
11004 .transpose()
11005 .map_err(schema_marker_read_failure)?;
11006 if rows.next().map_err(schema_marker_read_failure)?.is_some() {
11007 return Err(corrupt());
11008 }
11009 let Some(value) = value else {
11010 let has_descriptor = conn
11011 .query_row(
11012 "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
11013 [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
11014 |_| Ok(()),
11015 )
11016 .optional()
11017 .map_err(schema_marker_read_failure)?
11018 .is_some();
11019 if has_descriptor {
11020 return Err(corrupt());
11021 }
11022 return Ok(None);
11023 };
11024 if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11025 || !value.bytes().all(|b| b.is_ascii_digit())
11026 {
11027 return Err(corrupt());
11028 }
11029 let version = value.parse::<i32>().map_err(|_| corrupt())?;
11030 if version > requested {
11031 return Err(
11032 "client.schema_downgrade: persisted local schema is newer than the requested schema"
11033 .to_owned(),
11034 );
11035 }
11036 Ok(Some(version))
11037}
11038
11039impl SyncClient {
11040 pub fn new_with_identity(
11041 client_id: Option<String>,
11042 schema_json: &Value,
11043 limits: ClientLimits,
11044 ) -> Result<Self, String> {
11045 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
11046 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11047 Self::with_connection(resolved, schema_json, limits, conn)
11048 }
11049
11050 pub fn new(
11051 client_id: String,
11052 schema_json: &Value,
11053 limits: ClientLimits,
11054 ) -> Result<Self, String> {
11055 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11056 Self::with_connection(client_id, schema_json, limits, conn)
11057 }
11058
11059 pub fn open_path(
11065 client_id: String,
11066 schema_json: &Value,
11067 limits: ClientLimits,
11068 path: &str,
11069 ) -> Result<Self, String> {
11070 validate_client_limits(&limits)?;
11071 parse_schema_json(schema_json)?;
11072 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11073 Self::with_connection(client_id, schema_json, limits, conn)
11074 }
11075
11076 pub fn open_path_with_identity(
11077 client_id: Option<String>,
11078 schema_json: &Value,
11079 limits: ClientLimits,
11080 path: &str,
11081 ) -> Result<Self, String> {
11082 validate_client_limits(&limits)?;
11083 parse_schema_json(schema_json)?;
11086 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11087 conn.busy_timeout(std::time::Duration::from_millis(250))
11095 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
11096 let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
11097 let journal_mode: String = client
11098 .conn
11099 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
11100 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11101 if journal_mode != "wal" {
11102 return Err(
11103 "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
11104 );
11105 }
11106 Ok(client)
11107 }
11108
11109 #[must_use]
11110 pub fn client_id(&self) -> &str {
11111 &self.client_id
11112 }
11113
11114 pub fn with_connection(
11121 client_id: String,
11122 schema_json: &Value,
11123 limits: ClientLimits,
11124 conn: Connection,
11125 ) -> Result<Self, String> {
11126 Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
11127 }
11128
11129 fn with_connection_identity(
11132 client_id: Option<String>,
11133 schema_json: &Value,
11134 limits: ClientLimits,
11135 conn: Connection,
11136 ) -> Result<Self, String> {
11137 validate_client_limits(&limits)?;
11138 let schema = parse_schema_json(schema_json)?;
11139 validate_authority_reads(&schema, &limits.authority_reads)?;
11140 let previous_version = match limits.previous_version_context {
11143 Some(config) => {
11144 config.validate()?;
11145 Some(config)
11146 }
11147 None => None,
11148 };
11149 let mut client = SyncClient {
11150 #[cfg(feature = "bench-internals")]
11151 benchmark_phases: Recorder::default(),
11152 conn,
11153 schema,
11154 client_id: client_id
11155 .clone()
11156 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
11157 limits,
11158 subs: Vec::new(),
11159 outbox: Vec::new(),
11160 failed_commits: Vec::new(),
11161 retain_failed_commits: false,
11162 conflicts: Vec::new(),
11163 rejections: Vec::new(),
11164 schema_floor: None,
11165 lease_state: None,
11166 stopped: false,
11167 upgrading: false,
11168 sync_needed: false,
11169 realtime_policy: RealtimePolicy::Optional,
11170 realtime_state: RealtimeState::Disconnected,
11171 realtime_reason_code: None,
11172 realtime_retry_delay_ms: None,
11173 presence: HashMap::new(),
11174 now_ms: None,
11175 encryption: crate::values::EncryptionConfig::default(),
11176 security_preflight: false,
11177 transport_enabled: true,
11178 previous_version,
11179 insert_sql: RefCell::new(HashMap::new()),
11180 overlay_dirty: OverlayDirty::clean(),
11181 storage_failure: RefCell::new(None),
11182 #[cfg(test)]
11183 overlay_rebuild_count: Cell::new(0),
11184 #[cfg(test)]
11185 acknowledged_replay_count: Cell::new(0),
11186 #[cfg(test)]
11187 outcome_prune_count: Cell::new(0),
11188 progress: ProgressObserver::default(),
11189 change_queue: VecDeque::new(),
11190 sync_intent_queue: VecDeque::new(),
11191 retry_delay_ms: 250,
11192 round_retry_delay_ms: None,
11193 round_failure: None,
11194 active_round: None,
11195 last_round: None,
11196 last_change: None,
11197 query_failures: Vec::new(),
11198 coalesced: None,
11199 coalesced_failure: None,
11200 };
11201 client
11205 .conn
11206 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
11207 client
11213 .conn
11214 .execute_batch("BEGIN IMMEDIATE")
11215 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11216 let startup = (|| -> Result<(), String> {
11217 let marker = read_local_schema_version(&client.conn, client.schema.version)?;
11220 client.create_bookkeeping_tables()?;
11223 match client.get_meta(CLIENT_ID_KEY) {
11224 Some(existing) => match client_id {
11225 Some(requested) if existing != requested => {
11226 return Err(format!(
11227 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
11228 ))
11229 }
11230 _ => client.client_id = existing,
11231 },
11232 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
11233 }
11234 client.restore_persisted_state()?;
11235 client.failed_commits = client.load_failed_commits()?;
11236 match marker {
11237 None => {
11238 client.create_synced_tables()?;
11239 client.persist_schema_version()?;
11240 client.save_subscription_scope_schema();
11241 }
11242 Some(version) if version == client.schema.version => {
11243 client.create_synced_tables()?;
11244 set_local_schema_descriptor(&client.conn, &client.schema)
11248 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11249 client.save_subscription_scope_schema();
11250 }
11251 Some(_) => client.run_schema_reset()?,
11252 }
11253 client.reconcile_previous_version_at_boot()?;
11259 client.clear_satisfied_persisted_schema_floor();
11260 client.prune_unknown_subscriptions(false)?;
11261 client.drop_unencodable_outbox(false, None)?;
11270 if marker == Some(client.schema.version)
11271 && (!client.outbox.is_empty()
11272 || !client.failed_commits.is_empty()
11273 || client.has_acknowledged_rows())
11274 {
11275 client.overlay_dirty.set(true);
11279 client.rebuild_overlay()?;
11280 }
11281 client.enqueue_startup_sync_if_needed()?;
11288 Ok(())
11289 })();
11290 match startup {
11291 Ok(()) => match client.conn.execute_batch("COMMIT") {
11292 Ok(()) => Ok(client),
11293 Err(error) => {
11294 let failure = Self::sqlite_failure(&client.storage_failure, error);
11295 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
11296 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
11298 }
11299 Err(failure)
11300 }
11301 },
11302 Err(error) => {
11303 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
11304 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
11306 }
11307 Err(error)
11308 }
11309 }
11310 }
11311
11312 pub fn set_now_ms(&mut self, now_ms: i64) {
11315 self.now_ms = Some(now_ms);
11316 }
11317
11318 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
11322 self.encryption = encryption;
11323 }
11324
11325 #[must_use]
11326 pub fn security_lifecycle(&self) -> &'static str {
11327 if self.security_preflight {
11328 "preflight"
11329 } else {
11330 "active"
11331 }
11332 }
11333
11334 #[must_use]
11335 pub fn security_preflight(&self) -> bool {
11336 self.security_preflight
11337 }
11338
11339 pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
11341 if self.transport_enabled != enabled {
11342 self.transport_enabled = enabled;
11343 self.sync_intent_queue.clear();
11344 self.sync_intent_queue
11345 .push_back(if enabled && !self.security_preflight {
11346 SyncIntent::Interactive
11347 } else {
11348 SyncIntent::None
11349 });
11350 }
11351 if !enabled && self.active_round.is_none() {
11353 self.disconnect_realtime(transport);
11354 }
11355 }
11356
11357 pub fn transport_enabled(&self) -> bool {
11358 self.transport_enabled
11359 }
11360
11361 pub fn begin_security_preflight(&mut self) {
11368 self.seal_security_on_teardown();
11369 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
11372 }
11373
11374 pub fn seal_security_on_teardown(&mut self) {
11384 self.cancel_sync_round();
11385 self.security_preflight = true;
11386 self.encryption = crate::values::EncryptionConfig::default();
11387 self.sync_intent_queue.clear();
11388 }
11389
11390 pub fn activate_security(
11392 &mut self,
11393 encryption: crate::values::EncryptionConfig,
11394 ) -> Result<(), String> {
11395 if !self.security_preflight {
11396 return Err(
11397 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
11398 );
11399 }
11400 self.enqueue_startup_sync_if_needed()?;
11401 self.encryption = encryption;
11402 self.security_preflight = false;
11403 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
11404 Ok(())
11405 }
11406
11407 fn clock_now_ms(&self) -> i64 {
11408 self.now_ms.unwrap_or_else(crate::wall_clock_ms)
11409 }
11410
11411 fn create_bookkeeping_tables(&self) -> Result<(), String> {
11412 self.conn
11414 .execute_batch(
11415 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
11416 seq INTEGER PRIMARY KEY AUTOINCREMENT,
11417 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
11418 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
11419 tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
11420 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
11421 commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
11422 commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
11423 PRIMARY KEY(commit_id, idx));
11424 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
11425 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
11426 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
11427 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
11428 seq INTEGER PRIMARY KEY AUTOINCREMENT,
11429 client_commit_id TEXT NOT NULL UNIQUE,
11430 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
11431 recorded_at_ms INTEGER NOT NULL,
11432 results_json TEXT NOT NULL,
11433 operations_json TEXT,
11434 resolution TEXT NOT NULL DEFAULT 'active'
11435 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
11436 resolved_at_ms INTEGER,
11437 replacement_client_commit_id TEXT);
11438 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
11439 ON _syncular_commit_outcomes(resolution, seq);
11440 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
11441 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
11442 CREATE TABLE IF NOT EXISTS _syncular_meta (
11443 key TEXT PRIMARY KEY, value TEXT NOT NULL);
11444 CREATE TABLE IF NOT EXISTS _syncular_windows (
11445 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
11446 PRIMARY KEY (base, unit));
11447 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
11448 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
11449 effective_scopes TEXT NOT NULL);
11450 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
11451 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
11452 PRIMARY KEY(commit_id, blob_id));
11453 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
11454 ON _syncular_blob_commit_refs(blob_id);",
11455 )
11456 .map_err(|e| e.to_string())?;
11457 let _ = self
11460 .conn
11461 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
11462 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
11463 self.set_meta(LOCAL_REVISION_KEY, "0");
11464 }
11465 if self.schema_has_blobs() {
11468 let exists = self
11469 .conn
11470 .query_row(
11471 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
11472 [],
11473 |row| row.get::<_, bool>(0),
11474 )
11475 .map_err(|e| e.to_string())?;
11476 if exists {
11477 let mut statement = self
11478 .conn
11479 .prepare("PRAGMA table_info(_syncular_blobs)")
11480 .map_err(|e| e.to_string())?;
11481 let mut columns = statement
11482 .query_map([], |row| row.get::<_, String>(1))
11483 .map_err(|e| e.to_string())?
11484 .collect::<Result<Vec<_>, _>>()
11485 .map_err(|e| e.to_string())?;
11486 columns.sort();
11487 let mut expected = vec![
11488 "blob_id",
11489 "byte_length",
11490 "bytes",
11491 "created_at_ms",
11492 "media_type",
11493 ];
11494 expected.sort_unstable();
11495 if columns != expected {
11496 return Err(
11497 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
11498 );
11499 }
11500 } else {
11501 self.conn
11502 .execute_batch(
11503 "CREATE TABLE _syncular_blobs(
11504 blob_id TEXT PRIMARY KEY,
11505 bytes BLOB NOT NULL,
11506 byte_length INTEGER NOT NULL,
11507 media_type TEXT,
11508 created_at_ms INTEGER NOT NULL);",
11509 )
11510 .map_err(|e| e.to_string())?;
11511 }
11512 self.conn
11513 .execute_batch(
11514 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
11515 blob_id TEXT PRIMARY KEY,
11516 media_type TEXT,
11517 created_at_ms INTEGER NOT NULL);",
11518 )
11519 .map_err(|e| e.to_string())?;
11520 }
11521 Ok(())
11522 }
11523
11524 fn schema_has_blobs(&self) -> bool {
11526 self.schema
11527 .tables
11528 .iter()
11529 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
11530 }
11531
11532 fn get_meta(&self, key: &str) -> Option<String> {
11535 meta_get(&self.conn, key)
11536 }
11537
11538 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
11539 self.conn
11540 .query_row(
11541 "SELECT value FROM _syncular_meta WHERE key = ?1",
11542 rusqlite::params![key],
11543 |row| row.get::<_, String>(0),
11544 )
11545 .optional()
11546 .map_err(|_| {
11547 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
11548 })
11549 }
11550
11551 fn persist_schema_version(&self) -> Result<(), String> {
11554 self.conn
11555 .execute(
11556 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
11557 rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
11558 )
11559 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11560 set_local_schema_descriptor(&self.conn, &self.schema)
11561 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11562 }
11563
11564 fn set_meta(&self, key: &str, value: &str) {
11565 meta_set(&self.conn, key, value);
11566 }
11567
11568 fn delete_meta(&self, key: &str) {
11569 meta_delete(&self.conn, key);
11570 }
11571
11572 fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
11574 let mut stmt = self
11575 .conn
11576 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
11577 .map_err(|error| error.to_string())?;
11578 let rows = stmt
11579 .query_map([], |row| {
11580 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11581 })
11582 .map_err(|error| error.to_string())?;
11583 let mut commits = Vec::new();
11584 for row in rows {
11585 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
11586 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
11587 format!("invalid persisted outbox {client_commit_id:?}: {error}")
11588 })?;
11589 let mut ops = Vec::with_capacity(entries.len());
11590 for entry in entries {
11591 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
11592 ops.push(OutboxOp {
11593 upsert: op == "upsert",
11594 table: entry
11595 .get("table")
11596 .and_then(Value::as_str)
11597 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
11598 .to_owned(),
11599 row_id: entry
11600 .get("rowId")
11601 .and_then(Value::as_str)
11602 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
11603 .to_owned(),
11604 base_version: entry.get("baseVersion").and_then(Value::as_i64),
11605 values: entry.get("values").and_then(Value::as_object).cloned(),
11608 });
11609 }
11610 commits.push(OutboxCommit {
11611 client_commit_id,
11612 ops,
11613 });
11614 }
11615 Ok(commits)
11616 }
11617
11618 fn restore_persisted_state(&mut self) -> Result<(), String> {
11619 self.subs = {
11620 let mut stmt = self
11621 .conn
11622 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
11623 .map_err(|error| error.to_string())?;
11624 let rows = stmt
11625 .query_map([], |row| {
11626 Ok((
11627 row.get::<_, String>(0)?,
11628 row.get::<_, String>(1)?,
11629 row.get::<_, String>(2)?,
11630 ))
11631 })
11632 .map_err(|error| error.to_string())?;
11633 let mut subscriptions = Vec::new();
11634 for row in rows {
11635 let (id, table, raw) = row.map_err(|error| error.to_string())?;
11636 let state: Value = serde_json::from_str(&raw)
11637 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
11638 let requested = json_to_scope_map(
11639 state.get("requested").unwrap_or(&Value::Object(Map::new())),
11640 )?;
11641 let effective = state
11642 .get("effectiveScopes")
11643 .filter(|value| !value.is_null())
11644 .map(json_to_scope_map)
11645 .transpose()?;
11646 subscriptions.push(Subscription {
11647 id,
11648 table,
11649 requested,
11650 params: state
11651 .get("params")
11652 .and_then(Value::as_str)
11653 .map(str::to_owned),
11654 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
11655 bootstrap_state: state
11656 .get("bootstrapState")
11657 .and_then(Value::as_str)
11658 .map(str::to_owned),
11659 state: SubState::parse(
11660 state
11661 .get("status")
11662 .and_then(Value::as_str)
11663 .unwrap_or("active"),
11664 ),
11665 reason_code: state
11666 .get("reasonCode")
11667 .and_then(Value::as_str)
11668 .map(str::to_owned),
11669 effective,
11670 synced_once: state
11671 .get("syncedOnce")
11672 .and_then(Value::as_bool)
11673 .unwrap_or(false),
11674 });
11675 }
11676 subscriptions
11677 };
11678
11679 self.outbox = self.load_outbox()?;
11680
11681 self.prune_commit_outcomes()?;
11682 let active = self.commit_outcomes(CommitOutcomeQuery {
11683 active_only: true,
11684 ..CommitOutcomeQuery::default()
11685 })?;
11686 self.conflicts = active
11687 .iter()
11688 .flat_map(|outcome| outcome.results.iter())
11689 .filter_map(|result| match result {
11690 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
11691 _ => None,
11692 })
11693 .collect();
11694 self.rejections = active
11695 .iter()
11696 .flat_map(|outcome| outcome.results.iter())
11697 .filter_map(|result| match result {
11698 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
11699 _ => None,
11700 })
11701 .collect();
11702
11703 self.lease_state = self
11704 .get_meta(LEASE_STATE_KEY)
11705 .map(|raw| serde_json::from_str(&raw))
11706 .transpose()
11707 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
11708 self.schema_floor = self
11709 .get_meta(SCHEMA_FLOOR_KEY)
11710 .map(|raw| serde_json::from_str(&raw))
11711 .transpose()
11712 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
11713 self.stopped = self.schema_floor.is_some();
11714 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
11717 self.security_preflight = true;
11718 }
11719 Ok(())
11720 }
11721
11722 fn save_subscription_scope_schema(&self) {
11723 let declarations: BTreeMap<_, BTreeMap<_, _>> = self
11724 .schema
11725 .tables
11726 .iter()
11727 .map(|table| {
11728 (
11729 table.name.clone(),
11730 table
11731 .scope_variables
11732 .iter()
11733 .map(|scope| {
11734 (
11735 scope.variable.clone(),
11736 [scope.prefix.clone(), scope.column.clone()],
11737 )
11738 })
11739 .collect(),
11740 )
11741 })
11742 .collect();
11743 self.set_meta(
11744 "subscriptionScopeSchema",
11745 &serde_json::to_string(&declarations).expect("scope schema serialization"),
11746 );
11747 }
11748
11749 fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
11751 let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
11752 self.get_meta("subscriptionScopeSchema")
11753 .map(|raw| serde_json::from_str(&raw))
11754 .transpose()
11755 .map_err(|error| format!("invalid persisted scope schema: {error}"))?
11756 } else {
11757 None
11758 };
11759 let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
11760 let Some(table) = self.schema.table(table_name) else {
11761 return false;
11762 };
11763 if requested
11764 .iter()
11765 .any(|(variable, _)| table.scope_column(variable).is_none())
11766 {
11767 return false;
11768 }
11769 if !bump {
11770 return true;
11771 }
11772 let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
11773 return false;
11774 };
11775 let variables: BTreeSet<_> = if requested.is_empty() {
11776 old.keys()
11777 .cloned()
11778 .chain(
11779 table
11780 .scope_variables
11781 .iter()
11782 .map(|scope| scope.variable.clone()),
11783 )
11784 .collect()
11785 } else {
11786 requested
11787 .iter()
11788 .map(|(variable, _)| variable.clone())
11789 .collect()
11790 };
11791 variables.iter().all(|variable| {
11792 let current = table
11793 .scope_variables
11794 .iter()
11795 .find(|scope| &scope.variable == variable);
11796 match (old.get(variable), current) {
11797 (Some(old), Some(current)) => {
11798 old == &[current.prefix.clone(), current.column.clone()]
11799 }
11800 _ => false,
11801 }
11802 })
11803 };
11804 let mut stale_ids: BTreeSet<_> = self
11805 .subs
11806 .iter()
11807 .filter(|sub| !compatible(&sub.table, &sub.requested))
11808 .map(|sub| sub.id.clone())
11809 .collect();
11810 for (id, table, effective) in self.load_pending_evictions()? {
11811 if !compatible(&table, &effective) {
11812 stale_ids.insert(id);
11813 }
11814 }
11815 for id in &stale_ids {
11816 for sql in [
11817 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
11818 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
11819 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
11820 ] {
11821 self.conn
11822 .execute(sql, rusqlite::params![id])
11823 .map_err(|error| error.to_string())?;
11824 }
11825 }
11826 self.subs.retain(|sub| !stale_ids.contains(&sub.id));
11827 Ok(())
11828 }
11829
11830 #[must_use]
11831 pub fn local_revision(&self) -> u64 {
11832 self.get_meta(LOCAL_REVISION_KEY)
11833 .and_then(|value| value.parse().ok())
11834 .unwrap_or(0)
11835 }
11836
11837 pub fn snapshot_read(
11843 &mut self,
11844 request: &SnapshotReadRequest<'_>,
11845 owner: Option<QueryOwner<'_>>,
11846 ) -> Result<SnapshotRead, QueryReadFailure> {
11847 let result = snapshot_read_connection(&self.conn, request);
11848 if let Some(owner) = owner {
11849 self.record_query_read(owner, result.as_ref().err());
11850 }
11851 result
11852 }
11853
11854 #[must_use]
11855 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
11856 SyncStatusSnapshot {
11857 current_schema_version: self.schema.version,
11858 outbox: self.outbox.len(),
11859 upgrading: self.upgrading,
11860 lease_state: self.lease_state.clone(),
11861 schema_floor: self.schema_floor.clone(),
11862 sync_needed: self.sync_needed,
11863 previous_version_context: self.previous_version_status(),
11864 }
11865 }
11866
11867 fn previous_version_status(&self) -> PreviousVersionStatus {
11870 let Some(replica_path) = self.previous_version_replica_path() else {
11871 return PreviousVersionStatus {
11872 present: false,
11873 created_at_ms: None,
11874 };
11875 };
11876 match crate::previous_version::read_previous_version_record(&replica_path) {
11877 Ok(Some(record)) => PreviousVersionStatus {
11878 present: true,
11879 created_at_ms: Some(record.created_at_ms),
11880 },
11881 _ => PreviousVersionStatus {
11882 present: std::path::Path::new(
11883 &crate::previous_version::previous_version_container_path(&replica_path),
11884 )
11885 .exists(),
11886 created_at_ms: None,
11887 },
11888 }
11889 }
11890
11891 pub fn previous_version_snapshot(
11897 &self,
11898 spec: &PreviousVersionReadSpec,
11899 ) -> Result<PreviousVersionSnapshot, String> {
11900 let now_ms = self.clock_now_ms();
11901 crate::previous_version::previous_version_snapshot(
11902 &self.conn,
11903 self.previous_version_replica_path().as_deref(),
11904 self.previous_version.as_ref(),
11905 PreviousVersionLifecycle {
11906 lease_inactive: self.previous_version_lease_inactive(now_ms),
11907 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
11908 coverage_complete: self.previous_version_coverage_complete(),
11909 },
11910 now_ms,
11911 self.schema.version,
11912 spec,
11913 )
11914 }
11915
11916 #[must_use]
11918 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
11919 crate::previous_version::stored_previous_version_audit(&self.conn)
11920 }
11921
11922 pub fn previous_version_discard(
11926 &self,
11927 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
11928 self.drop_previous_version()
11929 }
11930
11931 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
11935 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
11936 present: false,
11937 discarded: false,
11938 };
11939 let Some(replica_path) = self.previous_version_replica_path() else {
11940 return absent;
11941 };
11942 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
11943 .unwrap_or(absent)
11944 }
11945
11946 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
11948 let Some(lease) = &self.lease_state else {
11949 return false;
11950 };
11951 if lease.error_code.is_some() {
11952 return true;
11953 }
11954 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
11955 }
11956
11957 fn previous_version_coverage_complete(&self) -> bool {
11961 let active = self
11962 .subs
11963 .iter()
11964 .filter(|sub| sub.state == SubState::Active)
11965 .collect::<Vec<_>>();
11966 if active.is_empty() {
11967 return false;
11968 }
11969 active
11970 .iter()
11971 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
11972 }
11973
11974 fn previous_version_lifetime_check(&self) {
11978 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
11979 return;
11980 };
11981 let now_ms = self.clock_now_ms();
11982 if self.previous_version_lease_inactive(now_ms) {
11983 let _ = self.drop_previous_version();
11984 return;
11985 }
11986 let Some(replica_path) = self.previous_version_replica_path() else {
11987 return;
11988 };
11989 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
11990 else {
11991 return;
11992 };
11993 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
11994 let _ = self.drop_previous_version();
11995 return;
11996 }
11997 if now_ms - record.created_at_ms > config.max_age_ms {
11998 let _ = self.drop_previous_version();
11999 return;
12000 }
12001 if self.previous_version_coverage_complete() {
12002 let _ = self.drop_previous_version();
12003 }
12004 }
12005
12006 pub fn diagnostics_snapshot(
12007 &self,
12008 request: &ClientDiagnosticsRequest,
12009 ) -> Result<ClientDiagnosticsSnapshot, String> {
12010 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
12011 return Err(format!(
12012 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
12013 ));
12014 }
12015 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
12016 for sub in &self.subs {
12017 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
12018 let complete =
12019 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
12020 let state = match sub.state {
12021 SubState::Revoked => "revoked",
12022 SubState::Failed => "failed",
12023 SubState::Active if reset => "reset",
12024 SubState::Active if complete => "complete",
12025 SubState::Active => "bootstrapping",
12026 };
12027 subscriptions.insert(
12028 sub.id.clone(),
12029 DiagnosticSubscription {
12030 id: sub.id.clone(),
12031 table: sub.table.clone(),
12032 state: state.to_owned(),
12033 complete,
12034 cursor: Some(sub.cursor),
12035 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
12036 },
12037 );
12038 }
12039 for expected in &request.expected_subscriptions {
12040 if expected.id.is_empty() || expected.table.is_empty() {
12041 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
12042 }
12043 if subscriptions
12044 .get(&expected.id)
12045 .is_some_and(|registered| registered.table != expected.table)
12046 {
12047 subscriptions.insert(
12048 expected.id.clone(),
12049 DiagnosticSubscription {
12050 id: expected.id.clone(),
12051 table: expected.table.clone(),
12052 state: "failed".to_owned(),
12053 complete: false,
12054 cursor: None,
12055 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
12056 },
12057 );
12058 } else {
12059 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
12060 DiagnosticSubscription {
12061 id: expected.id.clone(),
12062 table: expected.table.clone(),
12063 state: "unregistered".to_owned(),
12064 complete: false,
12065 cursor: None,
12066 reason_code: None,
12067 }
12068 });
12069 }
12070 }
12071 let mut ordered_subscriptions = Vec::new();
12072 let mut included = BTreeSet::new();
12073 for expected in &request.expected_subscriptions {
12074 if included.insert(expected.id.clone()) {
12075 if let Some(subscription) = subscriptions.get(&expected.id) {
12076 ordered_subscriptions.push(subscription.clone());
12077 }
12078 }
12079 }
12080 for (id, subscription) in &subscriptions {
12081 if included.insert(id.clone()) {
12082 ordered_subscriptions.push(subscription.clone());
12083 }
12084 }
12085 let subscriptions_truncated =
12086 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
12087 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
12088 let captured_at_ms = self.clock_now_ms();
12089 let lease = if let Some(error_code) = self
12090 .lease_state
12091 .as_ref()
12092 .and_then(|state| state.error_code.clone())
12093 {
12094 ClientDiagnosticsLease {
12095 state: "stopped".to_owned(),
12096 expires_at_ms: self
12097 .lease_state
12098 .as_ref()
12099 .and_then(|state| state.expires_at_ms),
12100 error_code: Some(Self::diagnostic_code(&error_code)),
12101 }
12102 } else if let Some(expires_at_ms) = self
12103 .lease_state
12104 .as_ref()
12105 .and_then(|state| state.expires_at_ms)
12106 {
12107 ClientDiagnosticsLease {
12108 state: if expires_at_ms <= captured_at_ms {
12109 "expired".to_owned()
12110 } else {
12111 "active".to_owned()
12112 },
12113 expires_at_ms: Some(expires_at_ms),
12114 error_code: None,
12115 }
12116 } else {
12117 ClientDiagnosticsLease {
12118 state: "none".to_owned(),
12119 expires_at_ms: None,
12120 error_code: None,
12121 }
12122 };
12123 let connectivity = match self.last_round.as_ref() {
12124 _ if !self.transport_enabled && self.active_round.is_none() => "offline",
12125 Some(round) if round.status == "succeeded" => "online",
12126 Some(round)
12127 if round.status == "failed"
12128 && round
12129 .error_code
12130 .as_deref()
12131 .is_some_and(Self::retryable_transport_code) =>
12132 {
12133 "offline"
12134 }
12135 _ => "unknown",
12136 };
12137 Ok(ClientDiagnosticsSnapshot {
12138 version: CLIENT_DIAGNOSTICS_VERSION,
12139 captured_at_ms,
12140 host: ClientDiagnosticsHost {
12141 kind: "direct".to_owned(),
12142 role: "single".to_owned(),
12143 connectivity: connectivity.to_owned(),
12144 realtime: self.realtime_state,
12145 realtime_policy: self.realtime_policy,
12146 realtime_reason_code: self.realtime_reason_code.clone(),
12147 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
12148 },
12149 security_lifecycle: self.security_lifecycle().to_owned(),
12150 schema: ClientDiagnosticsSchema {
12151 current_version: self.schema.version,
12152 upgrading: self.upgrading,
12153 required_version: self
12154 .schema_floor
12155 .as_ref()
12156 .and_then(|floor| floor.required_schema_version),
12157 latest_version: self
12158 .schema_floor
12159 .as_ref()
12160 .and_then(|floor| floor.latest_schema_version),
12161 },
12162 replica: ClientDiagnosticsReplica {
12163 local_revision: self.local_revision().to_string(),
12164 sync_needed: self.sync_needed,
12165 pending_outbox: self.outbox.len(),
12166 },
12167 lease,
12168 subscriptions: ordered_subscriptions,
12169 subscriptions_truncated,
12170 last_round: self.last_round.clone(),
12171 last_change: self.last_change.clone(),
12172 storage: self.diagnostics_storage(),
12173 query_failures: self.query_failures.clone(),
12174 })
12175 }
12176
12177 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
12178 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
12179 let page_count: i64 = self
12180 .conn
12181 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
12182 let page_size: i64 = self
12183 .conn
12184 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
12185 let outbox_bytes: i64 = self
12187 .conn
12188 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
12189 .query_row([], |row| row.get(0))?;
12190 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
12191 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
12192 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
12193 let blob_bytes = if self.schema_has_blobs() {
12194 self.conn
12195 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
12196 .query_row([], |row| row.get(0))?
12197 } else {
12198 0
12199 };
12200 let pressure = self
12201 .limits
12202 .blob_cache_max_bytes
12203 .is_some_and(|limit| blob_bytes > limit);
12204 Ok(ClientDiagnosticsStorage {
12205 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
12206 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
12207 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
12208 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
12209 retained_outcome_entries: Some(outcome_entries.max(0)),
12210 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
12211 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
12212 })
12213 };
12214 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
12215 status: "unreadable".to_owned(),
12216 database_bytes_approx: None,
12217 pending_outbox_bytes_approx: None,
12218 retained_outcome_bytes_approx: None,
12219 retained_outcome_entries: None,
12220 blob_cache_bytes_approx: None,
12221 pressure_reason_code: None,
12222 })
12223 }
12224
12225 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
12226 self.change_queue.drain(..).collect()
12227 }
12228
12229 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
12230 let intents = self.sync_intent_queue.drain(..).collect();
12231 if self.transport_enabled {
12232 intents
12233 } else {
12234 vec![SyncIntent::None]
12235 }
12236 }
12237
12238 fn schedule_background_retry(&mut self) -> u64 {
12239 let delay_ms = self.retry_delay_ms;
12240 self.sync_intent_queue
12241 .push_back(SyncIntent::Background { delay_ms });
12242 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
12243 self.round_retry_delay_ms = Some(delay_ms);
12244 delay_ms
12245 }
12246
12247 fn reset_background_retry(&mut self) {
12248 self.retry_delay_ms = 250;
12249 }
12250
12251 fn retryable_transport_code(code: &str) -> bool {
12252 code == "transport.failed"
12253 || code == "transport.unavailable"
12254 || code == "sync.transport_failed"
12255 }
12256
12257 fn retryable_failure_code(code: &str) -> bool {
12262 Self::retryable_transport_code(code)
12263 || matches!(
12264 code,
12265 "sync.auth_required"
12266 | "sync.auth_lease_required"
12267 | "sync.auth_lease_revoked"
12268 | "sync.internal_error"
12269 | "sync.idempotency_cache_miss"
12270 | "sync.schema_not_ready"
12271 | "sync.segment_expired"
12272 | "sync.rate_limited"
12273 | "sync.websocket_connection_limit"
12274 )
12275 }
12276
12277 fn diagnostic_code(code: &str) -> String {
12278 let valid = !code.is_empty()
12279 && code.len() <= 96
12280 && code.contains('.')
12281 && code
12282 .bytes()
12283 .next()
12284 .is_some_and(|byte| byte.is_ascii_lowercase())
12285 && code.bytes().all(|byte| {
12286 byte.is_ascii_lowercase()
12287 || byte.is_ascii_digit()
12288 || matches!(byte, b'.' | b'_' | b'-')
12289 });
12290 if valid {
12291 code.to_owned()
12292 } else {
12293 "client.unknown_failure".to_owned()
12294 }
12295 }
12296
12297 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
12298 if self.sync_needed != value {
12299 if self.begin_observation("syncular_status").is_ok() {
12300 self.sync_needed = value;
12301 let batch = ChangeAccumulator {
12302 status: true,
12303 ..ChangeAccumulator::default()
12304 };
12305 if self.finish_observation("syncular_status", batch).is_err() {
12306 self.rollback_observation("syncular_status");
12307 }
12308 } else {
12309 self.sync_needed = value;
12310 }
12311 }
12312 if value && interactive {
12313 self.sync_intent_queue.push_back(SyncIntent::Interactive);
12314 }
12315 }
12316
12317 fn sqlite_failure(
12318 storage_failure: &RefCell<Option<QueryReadFailure>>,
12319 error: rusqlite::Error,
12320 ) -> String {
12321 let message = error.to_string();
12322 let failure = QueryReadFailure::from(error);
12323 let code = failure.code;
12324 if code.is_some() && storage_failure.borrow().is_none() {
12325 *storage_failure.borrow_mut() = Some(failure);
12326 }
12327 match code {
12328 Some(code) => format!("{code}: {message}"),
12329 None => message,
12330 }
12331 }
12332
12333 fn begin_observation(&self, name: &str) -> Result<(), String> {
12334 if let Some(failure) = &self.coalesced_failure {
12335 return Err(failure.clone());
12336 }
12337 if self.coalesced.is_some() && self.conn.is_autocommit() {
12340 return Err("the coalesced apply transaction was rolled back".to_owned());
12341 }
12342 self.conn
12343 .execute_batch(&format!("SAVEPOINT {name}"))
12344 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12345 }
12346
12347 fn begin_coalesced_apply(&mut self) -> Result<(), String> {
12350 if self.coalesced.is_some() || !self.conn.is_autocommit() {
12351 return Ok(());
12352 }
12353 self.conn
12354 .execute_batch("BEGIN")
12355 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12356 self.coalesced = Some(CoalescedApply {
12357 rows: 0,
12358 subs: self.subs.clone(),
12359 outbox: self.outbox.clone(),
12360 failed_commits: self.failed_commits.clone(),
12361 conflicts: self.conflicts.clone(),
12362 rejections: self.rejections.clone(),
12363 schema_floor: self.schema_floor.clone(),
12364 lease_state: self.lease_state.clone(),
12365 stopped: self.stopped,
12366 upgrading: self.upgrading,
12367 sync_needed: self.sync_needed,
12368 overlay_dirty: self.overlay_dirty.snapshot(),
12369 change_queue: self.change_queue.len(),
12370 sync_intent_queue: self.sync_intent_queue.len(),
12371 last_change: self.last_change.clone(),
12372 });
12373 Ok(())
12374 }
12375
12376 fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
12379 let Some(open) = &self.coalesced else {
12380 return Ok(());
12381 };
12382 if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
12383 self.commit_coalesced_apply()?;
12384 self.begin_coalesced_apply()?;
12385 }
12386 if let Some(open) = &mut self.coalesced {
12387 open.rows += rows;
12388 }
12389 Ok(())
12390 }
12391
12392 fn commit_coalesced_apply(&mut self) -> Result<(), String> {
12397 let Some(open) = self.coalesced.take() else {
12398 return Ok(());
12399 };
12400 let committed = if self.conn.is_autocommit() {
12401 Err("the coalesced apply transaction was rolled back".to_owned())
12402 } else {
12403 self.conn
12404 .execute_batch("COMMIT")
12405 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12406 };
12407 let Err(error) = committed else {
12408 return Ok(());
12409 };
12410 if !self.conn.is_autocommit() {
12411 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12412 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
12413 }
12414 }
12415 self.subs = open.subs;
12416 self.outbox = open.outbox;
12417 self.failed_commits = open.failed_commits;
12418 self.conflicts = open.conflicts;
12419 self.rejections = open.rejections;
12420 self.schema_floor = open.schema_floor;
12421 self.lease_state = open.lease_state;
12422 self.stopped = open.stopped;
12423 self.upgrading = open.upgrading;
12424 self.sync_needed = open.sync_needed;
12425 self.overlay_dirty.restore(open.overlay_dirty);
12426 self.change_queue.truncate(open.change_queue);
12427 self.sync_intent_queue.truncate(open.sync_intent_queue);
12428 self.last_change = open.last_change;
12429 self.coalesced_failure = Some(error.clone());
12430 Err(error)
12431 }
12432
12433 fn finish_coalesced_apply(&mut self) -> Result<(), String> {
12435 let committed = self.commit_coalesced_apply();
12436 match self.coalesced_failure.take() {
12437 Some(failure) => Err(failure),
12438 None => committed,
12439 }
12440 }
12441
12442 fn rollback_observation(&self, name: &str) {
12443 if let Err(error) = self
12444 .conn
12445 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
12446 {
12447 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
12448 if failure.rollback_failure.is_none() {
12449 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
12450 }
12451 }
12452 }
12453 }
12454
12455 fn finish_observation(
12456 &mut self,
12457 name: &str,
12458 mut batch: ChangeAccumulator,
12459 ) -> Result<(), String> {
12460 #[cfg(feature = "bench-internals")]
12461 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
12462 if self.failed_commits.iter().any(|(commit, _)| {
12463 commit
12464 .ops
12465 .iter()
12466 .any(|operation| batch.tables.contains_key(&operation.table))
12467 }) {
12468 batch.outcomes = true;
12469 }
12470 if !batch.touched() {
12471 self.conn
12472 .execute_batch(&format!("RELEASE {name}"))
12473 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12474 return Ok(());
12475 }
12476 let revision = self
12477 .local_revision()
12478 .checked_add(1)
12479 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
12480 self.conn
12481 .execute(
12482 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12483 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
12484 )
12485 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12486 let status = batch.status.then(|| self.status_snapshot());
12487 let event = ClientChangeBatch {
12488 revision: revision.to_string(),
12489 tables: batch
12490 .tables
12491 .into_iter()
12492 .map(|(table, scope_keys)| TableChange {
12493 table,
12494 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
12495 })
12496 .collect(),
12497 windows: batch
12498 .windows
12499 .into_iter()
12500 .map(|((base_key, table), units)| WindowChange {
12501 base_key,
12502 table,
12503 units: units.into_iter().collect(),
12504 })
12505 .collect(),
12506 status,
12507 conflicts_changed: batch.conflicts,
12508 rejections_changed: batch.rejections,
12509 outcomes_changed: batch.outcomes,
12510 };
12511 self.conn
12512 .execute_batch(&format!("RELEASE {name}"))
12513 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12514 let mut diagnostic_tables = event
12515 .tables
12516 .iter()
12517 .map(|entry| entry.table.clone())
12518 .collect::<BTreeSet<_>>()
12519 .into_iter()
12520 .collect::<Vec<_>>();
12521 let mut diagnostic_windows = event
12522 .windows
12523 .iter()
12524 .map(|entry| entry.table.clone())
12525 .collect::<BTreeSet<_>>()
12526 .into_iter()
12527 .collect::<Vec<_>>();
12528 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
12529 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
12530 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
12531 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
12532 self.last_change = Some(DiagnosticLastChange {
12533 revision: event.revision.clone(),
12534 recorded_at_ms: self.clock_now_ms(),
12535 tables: diagnostic_tables,
12536 windows: diagnostic_windows,
12537 domains_truncated,
12538 status_changed: event.status.is_some(),
12539 conflicts_changed: event.conflicts_changed,
12540 rejections_changed: event.rejections_changed,
12541 outcomes_changed: event.outcomes_changed,
12542 });
12543 self.change_queue.push_back(event);
12544 Ok(())
12545 }
12546
12547 fn record_scope_map(
12548 &self,
12549 batch: &mut ChangeAccumulator,
12550 table_name: &str,
12551 scopes: &[(String, Vec<String>)],
12552 ) {
12553 let Some(table) = self.schema.table(table_name) else {
12554 return;
12555 };
12556 for (variable, values) in scopes {
12557 let Some(scope) = table
12558 .scope_variables
12559 .iter()
12560 .find(|scope| &scope.variable == variable)
12561 else {
12562 continue;
12563 };
12564 for value in values {
12565 batch.scope(table_name, format!("{}:{value}", scope.prefix));
12566 }
12567 }
12568 }
12569
12570 fn record_row_scopes(
12572 &self,
12573 batch: &mut ChangeAccumulator,
12574 table_name: &str,
12575 row_id: &str,
12576 base: bool,
12577 ) -> bool {
12578 let Some(table) = self.schema.table(table_name) else {
12579 return false;
12580 };
12581 if table.scope_variables.is_empty() {
12582 return false;
12583 }
12584 let columns = table
12585 .scope_variables
12586 .iter()
12587 .map(|scope| quote_ident(&scope.column))
12588 .collect::<Vec<_>>()
12589 .join(", ");
12590 let full_table = if base {
12591 base_table(table_name)
12592 } else {
12593 visible_table(table_name)
12594 };
12595 let sql = format!(
12596 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
12597 row_id_predicate(table)
12598 );
12599 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
12600 return false;
12601 };
12602 let values = stmt.query_row(rusqlite::params![row_id], |row| {
12603 let mut values = Vec::with_capacity(table.scope_variables.len());
12604 for index in 0..table.scope_variables.len() {
12605 values.push(row.get::<_, Option<String>>(index)?);
12606 }
12607 Ok(values)
12608 });
12609 let Ok(values) = values else {
12610 return false;
12611 };
12612 let mut recorded = false;
12613 for (scope, value) in table.scope_variables.iter().zip(values) {
12614 if let Some(value) = value {
12615 batch.scope(table_name, format!("{}:{value}", scope.prefix));
12616 recorded = true;
12617 }
12618 }
12619 recorded
12620 }
12621
12622 fn record_commit_changes(
12623 &self,
12624 batch: &mut ChangeAccumulator,
12625 tables: &[String],
12626 changes: &[ssp2::model::Change],
12627 ) {
12628 #[cfg(feature = "bench-internals")]
12629 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
12630 for change in changes {
12631 let Some(table_name) = tables.get(change.table_index as usize) else {
12632 continue;
12633 };
12634 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
12635 if let Some(table) = self.schema.table(table_name) {
12636 for (variable, value) in &change.scopes {
12637 if let Some(scope) = table
12638 .scope_variables
12639 .iter()
12640 .find(|scope| &scope.variable == variable)
12641 {
12642 batch.scope(table_name, format!("{}:{value}", scope.prefix));
12643 precise = true;
12644 }
12645 }
12646 }
12647 if !precise {
12648 batch.table(table_name);
12649 }
12650 }
12651 }
12652
12653 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
12654 if effective.is_empty() {
12655 return false;
12656 }
12657 let Some(table) = self.schema.table(table_name) else {
12658 return false;
12659 };
12660 let mut clauses = Vec::new();
12661 let mut params = Vec::new();
12662 for (variable, values) in effective {
12663 let Some(column) = table.scope_column(variable) else {
12664 return false;
12665 };
12666 if values.is_empty() {
12667 return false;
12668 }
12669 let placeholders = values
12670 .iter()
12671 .map(|value| {
12672 params.push(SqlValue::Text(value.clone()));
12673 "?"
12674 })
12675 .collect::<Vec<_>>()
12676 .join(", ");
12677 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
12678 }
12679 let sql = format!(
12680 "SELECT 1 FROM {} WHERE {} LIMIT 1",
12681 base_table(table_name),
12682 clauses.join(" AND ")
12683 );
12684 self.conn
12685 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
12686 .is_ok()
12687 }
12688
12689 pub fn upgrading(&self) -> bool {
12691 self.upgrading
12692 }
12693
12694 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
12702 let new_schema = parse_schema_json(schema_json)?;
12703 validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
12704 self.conn
12705 .execute_batch("BEGIN IMMEDIATE")
12706 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12707 let prior_schema = std::mem::replace(&mut self.schema, new_schema);
12708 let prior_subs = self.subs.clone();
12714 let prior_outbox = self.outbox.clone();
12715 let prior_rejections = self.rejections.clone();
12716 let prior_upgrading = self.upgrading;
12717 let prior_stopped = self.stopped;
12718 let prior_schema_floor = self.schema_floor.clone();
12719 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12720 let prior_sync_needed = self.sync_needed;
12721 let prior_active_round = self.active_round;
12722 let prior_changes = self.change_queue.len();
12723 let prior_intents = self.sync_intent_queue.len();
12724 let prior_last_change = self.last_change.clone();
12725 let guarded = (|| -> Result<(), String> {
12726 let marker = read_local_schema_version(&self.conn, self.schema.version)?;
12731 self.create_bookkeeping_tables()?;
12732 match marker {
12733 Some(version) if version < self.schema.version => self.run_schema_reset()?,
12734 None => {
12735 self.create_synced_tables()?;
12736 self.persist_schema_version()?;
12737 }
12738 _ => {}
12739 }
12740 self.outbox = self.load_outbox()?;
12745 self.drop_unencodable_outbox(false, None)?;
12750 if marker == Some(self.schema.version)
12751 && (!self.outbox.is_empty()
12752 || !self.failed_commits.is_empty()
12753 || self.has_acknowledged_rows())
12754 {
12755 self.overlay_dirty.set(true);
12756 self.rebuild_overlay()?;
12757 }
12758 self.prune_unknown_subscriptions(false)?;
12759 self.save_subscription_scope_schema();
12760 self.enqueue_startup_sync_if_needed()?;
12764 Ok(())
12765 })();
12766 let outcome = match guarded {
12767 Ok(()) => self
12768 .conn
12769 .execute_batch("COMMIT")
12770 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
12771 Err(error) => Err(error),
12772 };
12773 if let Err(error) = outcome {
12774 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12775 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
12777 }
12778 self.schema = prior_schema;
12779 self.subs = prior_subs;
12780 self.outbox = prior_outbox;
12781 self.rejections = prior_rejections;
12782 self.upgrading = prior_upgrading;
12783 self.stopped = prior_stopped;
12784 self.schema_floor = prior_schema_floor;
12785 self.overlay_dirty.restore(prior_overlay_dirty);
12786 self.sync_needed = prior_sync_needed;
12787 self.active_round = prior_active_round;
12788 self.change_queue.truncate(prior_changes);
12789 self.sync_intent_queue.truncate(prior_intents);
12790 self.last_change = prior_last_change;
12791 self.insert_sql.borrow_mut().clear();
12793 return Err(error);
12794 }
12795 Ok(())
12796 }
12797
12798 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
12799 let startup_work = !self.stopped
12800 && (!self.outbox.is_empty()
12801 || self.has_acknowledged_rows()
12802 || !self.load_pending_evictions()?.is_empty()
12803 || self.subs.iter().any(|sub| sub.state == SubState::Active));
12804 if startup_work {
12805 self.sync_needed = true;
12806 self.sync_intent_queue.push_back(SyncIntent::Interactive);
12807 }
12808 Ok(())
12809 }
12810
12811 fn clear_satisfied_persisted_schema_floor(&mut self) {
12818 let satisfied = self
12819 .schema_floor
12820 .as_ref()
12821 .and_then(|floor| floor.required_schema_version)
12822 .is_some_and(|required| self.schema.version >= required);
12823 if !satisfied {
12824 return;
12825 }
12826 self.schema_floor = None;
12827 self.stopped = false;
12828 self.delete_meta(SCHEMA_FLOOR_KEY);
12829 }
12830
12831 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
12836 let Some(replica_path) = self.previous_version_replica_path() else {
12837 return Ok(());
12838 };
12839 let max_age_ms = self
12840 .previous_version
12841 .filter(|config| config.enabled)
12842 .map(|config| config.max_age_ms);
12843 reconcile_previous_version_at_boot(
12844 &self.conn,
12845 &replica_path,
12846 self.schema.version,
12847 max_age_ms,
12848 self.clock_now_ms(),
12849 )
12850 }
12851
12852 fn previous_version_replica_path(&self) -> Option<String> {
12856 let path = self.conn.path()?;
12857 if path.is_empty() || path.starts_with("file:") {
12858 return None;
12859 }
12860 Some(path.to_owned())
12861 }
12862
12863 fn run_schema_reset(&mut self) -> Result<(), String> {
12869 self.begin_observation("syncular_schema_reset")?;
12870 let prior_subs = self.subs.clone();
12871 let prior_outbox = self.outbox.clone();
12872 let prior_rejections = self.rejections.clone();
12873 let prior_upgrading = self.upgrading;
12874 let prior_stopped = self.stopped;
12875 let prior_schema_floor = self.schema_floor.clone();
12876 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12877 let prior_sync_needed = self.sync_needed;
12878 let prior_active_round = self.active_round;
12879 let mut batch = ChangeAccumulator::default();
12880 let result = self
12881 .run_schema_reset_observed(&mut batch, true)
12882 .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
12883 if let Err(error) = result {
12884 self.rollback_observation("syncular_schema_reset");
12885 self.subs = prior_subs;
12886 self.outbox = prior_outbox;
12887 self.rejections = prior_rejections;
12888 self.upgrading = prior_upgrading;
12889 self.stopped = prior_stopped;
12890 self.schema_floor = prior_schema_floor;
12891 self.overlay_dirty.restore(prior_overlay_dirty);
12892 self.sync_needed = prior_sync_needed;
12893 self.active_round = prior_active_round;
12894 return Err(error);
12895 }
12896 Ok(())
12897 }
12898
12899 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
12900 if self.get_meta(LOG_EPOCH_KEY).is_none() {
12901 self.begin_observation("syncular_epoch_acquisition")?;
12902 let previous_sync_needed = self.sync_needed;
12903 if let Err(error) = self.conn.execute(
12904 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12905 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
12906 ) {
12907 self.rollback_observation("syncular_epoch_acquisition");
12908 return Err(Self::sqlite_failure(&self.storage_failure, error));
12909 }
12910 self.sync_needed = true;
12911 let batch = ChangeAccumulator {
12912 status: true,
12913 ..ChangeAccumulator::default()
12914 };
12915 if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
12916 self.rollback_observation("syncular_epoch_acquisition");
12917 self.sync_needed = previous_sync_needed;
12918 return Err(error);
12919 }
12920 self.sync_intent_queue.push_back(SyncIntent::Interactive);
12921 return Ok(Vec::new());
12922 }
12923 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
12924 self.begin_observation("syncular_log_epoch_reset")?;
12925 let prior_subs = self.subs.clone();
12926 let prior_outbox = self.outbox.clone();
12927 let prior_rejections = self.rejections.clone();
12928 let prior_upgrading = self.upgrading;
12929 let prior_stopped = self.stopped;
12930 let prior_schema_floor = self.schema_floor.clone();
12931 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12932 let prior_sync_needed = self.sync_needed;
12933 let prior_active_round = self.active_round;
12934 let mut batch = ChangeAccumulator::default();
12935 let result = self
12936 .run_schema_reset_observed(&mut batch, false)
12937 .and_then(|()| {
12938 self.conn
12939 .execute(
12940 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12941 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
12942 )
12943 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12944 self.sync_needed = true;
12945 batch.status = true;
12946 self.finish_observation("syncular_log_epoch_reset", batch)
12947 });
12948 if let Err(error) = result {
12949 self.rollback_observation("syncular_log_epoch_reset");
12950 self.subs = prior_subs;
12951 self.outbox = prior_outbox;
12952 self.rejections = prior_rejections;
12953 self.upgrading = prior_upgrading;
12954 self.stopped = prior_stopped;
12955 self.schema_floor = prior_schema_floor;
12956 self.overlay_dirty.restore(prior_overlay_dirty);
12957 self.sync_needed = prior_sync_needed;
12958 self.active_round = prior_active_round;
12959 return Err(error);
12960 }
12961 self.sync_intent_queue.push_back(SyncIntent::Interactive);
12962 Ok(resets)
12963 }
12964
12965 fn run_schema_reset_observed(
12966 &mut self,
12967 batch: &mut ChangeAccumulator,
12968 capture: bool,
12969 ) -> Result<(), String> {
12970 read_local_schema_version(&self.conn, self.schema.version)?;
12976 self.conn
12977 .execute_batch(
12978 "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
12979 )
12980 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12981 self.cancel_sync_round();
12982 self.upgrading = true;
12983 batch.status = true;
12984 if self.previous_version.is_some_and(|config| config.enabled) {
12992 let previous_version = self
12993 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
12994 .and_then(|value| value.parse::<i32>().ok())
12995 .unwrap_or(self.schema.version);
12996 let pending = self
12997 .outbox
12998 .iter()
12999 .map(|commit| PendingCommitAudit {
13000 commit_id: &commit.client_commit_id,
13001 operations: commit_audit_operations(commit),
13002 })
13003 .collect::<Vec<_>>();
13004 crate::previous_version::write_previous_version_audit(
13005 &self.conn,
13006 &crate::previous_version::build_previous_version_audit(
13007 &self.schema,
13008 &pending,
13009 previous_version,
13010 self.schema.version,
13011 self.clock_now_ms(),
13012 ),
13013 );
13014 }
13015 if let Some(replica_path) = self.previous_version_replica_path() {
13020 sweep_previous_version_container(&self.conn, &replica_path)?;
13021 if capture {
13022 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
13023 let previous_version = self
13024 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13025 .and_then(|value| value.parse::<i32>().ok())
13026 .unwrap_or(self.schema.version);
13027 capture_previous_version_from_replica(
13028 &self.conn,
13029 &replica_path,
13030 previous_version,
13031 self.schema.version,
13032 &config,
13033 self.clock_now_ms(),
13034 )?;
13035 }
13036 }
13037 }
13038 for table in &self.schema.tables {
13039 batch.table(&table.name);
13040 }
13041 for (base_key, unit, table) in self.load_registered_window_units() {
13042 batch.window(&base_key, &table, &unit);
13043 }
13044 if capture {
13045 self.prune_unknown_subscriptions(true)?;
13046 }
13047 self.insert_sql.borrow_mut().clear();
13049 self.overlay_dirty.set(true);
13050 let virtual_tables: Vec<String> = {
13058 let mut stmt = self
13059 .conn
13060 .prepare(
13061 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
13062 )
13063 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13064 let rows = stmt
13065 .query_map([], |row| row.get::<_, String>(0))
13066 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13067 rows.filter_map(Result::ok)
13068 .filter(|name| is_synced_table_name(name))
13069 .collect()
13070 };
13071 for name in virtual_tables {
13072 self.conn
13073 .execute_batch(&format!(
13074 "DROP TABLE IF EXISTS {}",
13075 quote_ident(&format!("_syncular_fts_{name}"))
13076 ))
13077 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13078 self.conn
13079 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
13080 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13081 }
13082 let existing: Vec<String> = {
13083 let mut stmt = self
13084 .conn
13085 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
13086 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13087 let rows = stmt
13088 .query_map([], |row| row.get::<_, String>(0))
13089 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13090 rows.filter_map(Result::ok)
13091 .filter(|name| is_synced_table_name(name))
13092 .collect()
13093 };
13094 for name in &existing {
13095 if let Some(table) = name.strip_prefix("_syncular_base_") {
13096 batch.table(table);
13097 } else if !name.starts_with("_syncular_") {
13098 batch.table(name);
13099 }
13100 }
13101 for name in existing {
13102 self.conn
13103 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
13104 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13105 }
13106 self.create_synced_tables()?;
13108 for sub in &mut self.subs {
13110 sub.cursor = -1;
13111 sub.bootstrap_state = None;
13112 sub.effective = None;
13113 sub.state = SubState::Active;
13114 sub.reason_code = None;
13115 sub.synced_once = false;
13116 }
13117 let subs = self.subs.clone();
13118 for sub in &subs {
13119 self.persist_sub(sub)?;
13120 }
13121 self.stopped = false;
13123 self.schema_floor = None;
13124 self.delete_meta(SCHEMA_FLOOR_KEY);
13125 self.persist_schema_version()?;
13127 self.save_subscription_scope_schema();
13128 self.drop_unencodable_outbox(false, Some(batch))?;
13139 self.rebuild_overlay()?;
13141 Ok(())
13142 }
13143
13144 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
13151 let incompatible = self
13152 .outbox
13153 .iter()
13154 .filter(|commit| {
13155 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
13156 })
13157 .cloned()
13158 .collect::<Vec<_>>();
13159 if incompatible.is_empty() {
13160 return Ok(false);
13161 }
13162 let prior_outbox = self.outbox.clone();
13168 let prior_rejections = self.rejections.clone();
13169 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13170 let owns_transaction = self.conn.is_autocommit();
13171 self.begin_observation("syncular_outbox_drop")?;
13172 let mut batch = ChangeAccumulator::default();
13173 let dropped = (|| -> Result<(), String> {
13174 let mut rejections = Vec::new();
13175 for commit in &incompatible {
13176 let results = commit
13177 .ops
13178 .iter()
13179 .enumerate()
13180 .map(|(op_index, operation)| {
13181 let rejection = RejectionRecord {
13182 client_commit_id: commit.client_commit_id.clone(),
13183 op_index: op_index as i32,
13184 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
13185 message: "the persisted commit cannot encode under the current schema"
13186 .to_owned(),
13187 retryable: false,
13188 details: None,
13189 operation: Some(CommitOperation::from(operation)),
13190 };
13191 rejections.push(rejection.clone());
13192 CommitOperationOutcome::Error { rejection }
13193 })
13194 .collect::<Vec<_>>();
13195 self.persist_commit_outcome(
13196 &commit.client_commit_id,
13197 CommitOutcomeStatus::Rejected,
13198 &results,
13199 Some(&commit.ops),
13200 )?;
13201 self.delete_outbox_persisted(&commit.client_commit_id)?;
13202 for operation in &commit.ops {
13205 if self.schema.table(&operation.table).is_some() {
13206 batch.table(&operation.table);
13207 }
13208 }
13209 }
13210 self.prune_commit_outcomes()?;
13211 let incompatible_ids = incompatible
13212 .iter()
13213 .map(|commit| commit.client_commit_id.as_str())
13214 .collect::<BTreeSet<_>>();
13215 self.outbox
13216 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
13217 self.rejections.extend(rejections);
13218 self.overlay_dirty.set(true);
13221 self.rebuild_overlay()?;
13222 batch.status = true;
13223 batch.rejections = true;
13224 batch.outcomes = true;
13225 Ok(())
13226 })();
13227 let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
13228 if let Err(error) = result {
13229 if owns_transaction {
13233 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13234 let mut failure = self.storage_failure.borrow_mut();
13235 let failure =
13236 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
13237 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
13238 }
13239 } else {
13240 self.rollback_observation("syncular_outbox_drop");
13241 }
13242 self.outbox = prior_outbox;
13243 self.rejections = prior_rejections;
13244 self.overlay_dirty.restore(prior_overlay_dirty);
13245 return Err(error);
13246 }
13247 Ok(true)
13248 }
13249
13250 fn create_synced_tables(&self) -> Result<(), String> {
13253 for table in &self.schema.tables {
13254 for (full, index_prefix) in [
13258 (base_table(&table.name), "_syncular_base_"),
13259 (visible_table(&table.name), ""),
13260 ] {
13261 let mut cols: Vec<String> =
13262 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
13263 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
13264 let sql = format!(
13265 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
13266 cols.join(", "),
13267 quote_ident(&table.primary_key)
13268 );
13269 self.conn
13270 .execute(&sql, [])
13271 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13272 for index in &table.indexes {
13276 let unique = if index.unique { "UNIQUE " } else { "" };
13277 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
13278 let cols_sql = index
13279 .columns
13280 .iter()
13281 .map(|c| quote_ident(c))
13282 .collect::<Vec<_>>()
13283 .join(", ");
13284 let index_sql = format!(
13285 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
13286 );
13287 self.conn
13288 .execute(&index_sql, [])
13289 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13290 }
13291 }
13292 }
13293 for table in &self.schema.tables {
13296 for index in &table.fts_indexes {
13297 self.create_fts_projection(table, index)?;
13298 }
13299 }
13300 Ok(())
13301 }
13302
13303 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
13304 let count: i64 = self
13305 .conn
13306 .query_row(
13307 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
13308 rusqlite::params![index.name],
13309 |row| row.get(0),
13310 )
13311 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13312 Ok(count > 0)
13313 }
13314
13315 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
13316 for suffix in ["ai", "ad", "au"] {
13317 self.conn
13318 .execute(
13319 &format!(
13320 "DROP TRIGGER IF EXISTS {}",
13321 quote_ident(&format!("{}_{suffix}", index.name))
13322 ),
13323 [],
13324 )
13325 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13326 }
13327 Ok(())
13328 }
13329
13330 fn create_fts_triggers(
13331 &self,
13332 table: &TableSchema,
13333 index: &FtsIndexSchema,
13334 ) -> Result<(), String> {
13335 let fts = quote_ident(&index.name);
13336 let source = visible_table(&table.name);
13337 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
13338 let pk = quote_ident(&table.primary_key);
13339 let projection_columns = std::iter::once(source_id.clone())
13340 .chain(index.columns.iter().map(|column| quote_ident(column)))
13341 .collect::<Vec<_>>()
13342 .join(", ");
13343 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
13344 .chain(
13345 index
13346 .columns
13347 .iter()
13348 .map(|column| format!("new.{}", quote_ident(column))),
13349 )
13350 .collect::<Vec<_>>()
13351 .join(", ");
13352 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
13353 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)");
13354 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)");
13355 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})");
13356 let bi = quote_ident(&format!("{}_bi", index.name));
13357 let ai = quote_ident(&format!("{}_ai", index.name));
13358 let ad = quote_ident(&format!("{}_ad", index.name));
13359 let au = quote_ident(&format!("{}_au", index.name));
13360 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
13361 let sql = format!(
13362 "DROP TRIGGER IF EXISTS {bi};
13363 DROP TRIGGER IF EXISTS {ai};
13364 DROP TRIGGER IF EXISTS {ad};
13365 DROP TRIGGER IF EXISTS {au};
13366 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
13367 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
13368 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
13369 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
13370 );
13371 self.conn
13372 .execute_batch(&sql)
13373 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13374 }
13375
13376 fn rebuild_fts_projection(
13377 &self,
13378 table: &TableSchema,
13379 index: &FtsIndexSchema,
13380 ) -> Result<(), String> {
13381 let fts = quote_ident(&index.name);
13382 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
13383 let pk = quote_ident(&table.primary_key);
13384 let indexed_columns = index
13385 .columns
13386 .iter()
13387 .map(|column| quote_ident(column))
13388 .collect::<Vec<_>>();
13389 let projection_columns = std::iter::once(source_id.clone())
13390 .chain(indexed_columns.iter().cloned())
13391 .collect::<Vec<_>>()
13392 .join(", ");
13393 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
13394 let sql = format!(
13395 "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};",
13396 columns = indexed_columns.join(", "),
13397 source = visible_table(&table.name),
13398 );
13399 self.conn
13400 .execute_batch(&sql)
13401 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13402 }
13403
13404 fn create_fts_projection(
13405 &self,
13406 table: &TableSchema,
13407 index: &FtsIndexSchema,
13408 ) -> Result<(), String> {
13409 self.begin_observation("syncular_fts_schema")?;
13410 let result = (|| {
13411 let existed = self.fts_projection_exists(index)?;
13412 let tokenizer = index.tokenize.replace('\'', "''");
13413 let columns = index
13414 .columns
13415 .iter()
13416 .map(|column| quote_ident(column))
13417 .collect::<Vec<_>>()
13418 .join(", ");
13419 let sql = format!(
13420 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
13421 fts = quote_ident(&index.name),
13422 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
13423 );
13424 self.conn.execute(&sql, []).map_err(|error| {
13425 format!(
13426 "cannot create local FTS5 projection {:?}: {error}",
13427 index.name
13428 )
13429 })?;
13430 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
13431 let mapped: bool = self
13432 .conn
13433 .query_row(
13434 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
13435 [format!("_syncular_fts_{}", index.name)],
13436 |row| row.get(0),
13437 )
13438 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13439 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))?;
13440 if !mapped && existed {
13441 self.conn
13442 .execute_batch(&format!(
13443 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
13444 quote_ident(FTS_SOURCE_ID_COLUMN),
13445 quote_ident(&index.name)
13446 ))
13447 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13448 }
13449 self.create_fts_triggers(table, index)?;
13450 if !existed {
13451 self.rebuild_fts_projection(table, index)?;
13452 }
13453 Ok(())
13454 })();
13455 if let Err(error) = result {
13456 self.rollback_observation("syncular_fts_schema");
13457 return Err(error);
13458 }
13459 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
13460 self.rollback_observation("syncular_fts_schema");
13461 return Err(error.to_string());
13462 }
13463 Ok(())
13464 }
13465
13466 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
13469 let state = serde_json::json!({
13470 "requested": scope_map_to_json(&sub.requested),
13471 "params": sub.params,
13472 "cursor": sub.cursor,
13473 "bootstrapState": sub.bootstrap_state,
13474 "status": sub.state.name(),
13475 "reasonCode": sub.reason_code,
13476 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
13477 "syncedOnce": sub.synced_once,
13478 });
13479 self.conn.execute(
13480 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
13481 rusqlite::params![sub.id, sub.table, state.to_string()],
13482 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13483 }
13484
13485 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
13486 let ops: Vec<Value> = commit
13487 .ops
13488 .iter()
13489 .map(|op| {
13490 serde_json::json!({
13491 "op": if op.upsert { "upsert" } else { "delete" },
13492 "table": op.table,
13493 "rowId": op.row_id,
13494 "baseVersion": op.base_version,
13495 "values": op.values.clone().map(Value::Object),
13496 })
13497 })
13498 .collect();
13499 self.conn
13500 .execute(
13501 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
13502 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
13503 )
13504 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13505 for operation in &commit.ops {
13506 let Some(values) = &operation.values else {
13507 continue;
13508 };
13509 let Some(table) = self.schema.table(&operation.table) else {
13510 continue;
13511 };
13512 for column in table
13513 .columns
13514 .iter()
13515 .filter(|column| column.ty == ColumnType::BlobRef)
13516 {
13517 let Some(Value::String(raw)) = values.get(&column.name) else {
13518 continue;
13519 };
13520 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
13521 continue;
13522 };
13523 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
13524 continue;
13525 };
13526 self.conn
13527 .execute(
13528 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
13529 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
13530 rusqlite::params![commit.client_commit_id, blob_id],
13531 )
13532 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13533 }
13534 }
13535 Ok(())
13536 }
13537
13538 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
13539 if let Some(commit) = self
13540 .outbox
13541 .iter()
13542 .find(|commit| commit.client_commit_id == client_commit_id)
13543 {
13544 for operation in &commit.ops {
13545 self.overlay_dirty.table(&operation.table);
13546 }
13547 }
13548
13549 self.conn
13550 .execute(
13551 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
13552 rusqlite::params![client_commit_id],
13553 )
13554 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13555 self.conn
13556 .execute(
13557 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
13558 rusqlite::params![client_commit_id],
13559 )
13560 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13561 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)", [])
13562 .map(|_| ())
13563 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13564 }
13565
13566 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
13567 match status {
13568 CommitOutcomeStatus::Applied => "applied",
13569 CommitOutcomeStatus::Cached => "cached",
13570 CommitOutcomeStatus::Conflict => "conflict",
13571 CommitOutcomeStatus::Rejected => "rejected",
13572 }
13573 }
13574
13575 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
13576 match resolution {
13577 CommitOutcomeResolution::Active => "active",
13578 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
13579 CommitOutcomeResolution::Superseded => "superseded",
13580 CommitOutcomeResolution::Dismissed => "dismissed",
13581 }
13582 }
13583
13584 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
13585 match value {
13586 "applied" => Ok(CommitOutcomeStatus::Applied),
13587 "cached" => Ok(CommitOutcomeStatus::Cached),
13588 "conflict" => Ok(CommitOutcomeStatus::Conflict),
13589 "rejected" => Ok(CommitOutcomeStatus::Rejected),
13590 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
13591 }
13592 }
13593
13594 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
13595 match value {
13596 "active" => Ok(CommitOutcomeResolution::Active),
13597 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
13598 "superseded" => Ok(CommitOutcomeResolution::Superseded),
13599 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
13600 _ => Err(format!(
13601 "invalid persisted commit outcome resolution {value:?}"
13602 )),
13603 }
13604 }
13605
13606 fn persist_commit_outcome(
13607 &self,
13608 client_commit_id: &str,
13609 status: CommitOutcomeStatus,
13610 results: &[CommitOperationOutcome],
13611 operations: Option<&[OutboxOp]>,
13612 ) -> Result<(), String> {
13613 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
13614 let operations_json = operations
13615 .map(|items| {
13616 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
13617 })
13618 .transpose()
13619 .map_err(|error| error.to_string())?;
13620 self.conn
13621 .execute(
13622 "INSERT INTO _syncular_commit_outcomes (
13623 client_commit_id, status, recorded_at_ms, results_json,
13624 operations_json, resolution
13625 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
13626 rusqlite::params![
13627 client_commit_id,
13628 Self::outcome_status_name(status),
13629 self.clock_now_ms(),
13630 results_json,
13631 operations_json
13632 ],
13633 )
13634 .map(|_| ())
13635 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13636 }
13637
13638 fn stored_cell_is_representable(cell: &Value) -> bool {
13641 cell.is_null()
13642 || cell.is_string()
13643 || cell.is_boolean()
13644 || cell.is_number()
13645 || is_canonical_bytes_envelope(cell)
13646 }
13647
13648 fn stored_row_is_representable(value: &Value) -> bool {
13649 value
13650 .as_object()
13651 .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
13652 }
13653
13654 fn stored_operation_is_representable(value: &Value) -> bool {
13655 value.get("table").is_some_and(Value::is_string)
13656 && value.get("rowId").is_some_and(Value::is_string)
13657 && value.get("baseVersion").is_none_or(|v| {
13658 v.as_i64()
13659 .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
13660 })
13661 && match value.get("op").and_then(Value::as_str) {
13662 Some("upsert") => value
13663 .get("values")
13664 .is_some_and(Self::stored_row_is_representable),
13665 Some("delete") => value.get("values").is_none(),
13666 _ => false,
13667 }
13668 }
13669
13670 fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
13674 serde_json::to_value(CommitOperation::from(operation))
13675 .is_ok_and(|value| Self::stored_operation_is_representable(&value))
13676 }
13677
13678 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
13679 let StoredCommitOutcomeRow {
13680 sequence,
13681 client_commit_id,
13682 status,
13683 recorded_at_ms,
13684 results_json,
13685 operations_json,
13686 resolution,
13687 resolved_at_ms,
13688 replacement_client_commit_id,
13689 } = row;
13690 if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
13691 || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
13692 || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
13693 {
13694 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
13695 }
13696 let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
13697 let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
13698 for result in &stored {
13699 let nested = match result.get("status").and_then(Value::as_str) {
13700 Some("conflict") => result.get("conflict"),
13701 Some("error") => result.get("rejection"),
13702 _ => None,
13703 };
13704 if nested
13705 .and_then(|record| record.get("operation"))
13706 .is_some_and(|op| !Self::stored_operation_is_representable(op))
13707 || (result.get("status").and_then(Value::as_str) == Some("conflict")
13708 && nested
13709 .and_then(|record| record.get("serverRow"))
13710 .is_some_and(|row| !Self::stored_row_is_representable(row)))
13711 {
13712 return Err(invalid());
13713 }
13714 }
13715 let results: Vec<CommitOperationOutcome> =
13716 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
13717 let has_conflict = results
13718 .iter()
13719 .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
13720 let has_error = results
13721 .iter()
13722 .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
13723 let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
13724 let redacted = results.is_empty() && operations_json.is_none();
13725 if match status {
13726 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
13727 CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
13728 CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
13729 } {
13730 return Err(invalid());
13731 }
13732 let operations: Option<Vec<CommitOperation>> = operations_json
13733 .map(|raw| {
13734 let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
13735 if !stored.iter().all(Self::stored_operation_is_representable) {
13736 return Err(invalid());
13737 }
13738 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
13739 })
13740 .transpose()?;
13741 for result in &results {
13742 match result {
13743 CommitOperationOutcome::Conflict { conflict }
13744 if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
13745 {
13746 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
13747 }
13748 CommitOperationOutcome::Error { rejection } => {
13749 if let Some(details) = &rejection.details {
13750 let encoded = serde_json::to_string(details).map_err(|_| {
13751 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
13752 })?;
13753 RejectionDetails::parse(&encoded).map_err(|_| {
13754 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
13755 })?;
13756 }
13757 }
13758 _ => {}
13759 }
13760 }
13761 Ok(CommitOutcome {
13762 sequence,
13763 client_commit_id,
13764 status,
13765 recorded_at_ms,
13766 results,
13767 operations,
13768 retained_rows: None,
13769 resolution: Self::parse_outcome_resolution(&resolution)?,
13770 resolved_at_ms,
13771 replacement_client_commit_id,
13772 })
13773 }
13774
13775 fn unique_conflicts(
13777 &self,
13778 table: &TableSchema,
13779 values: &Map<String, Value>,
13780 base: bool,
13781 ) -> Result<Vec<RetainedUniqueConflict>, String> {
13782 let source = if base {
13783 base_table(&table.name)
13784 } else {
13785 visible_table(&table.name)
13786 };
13787 let mut conflicts = Vec::new();
13788 for index in table.indexes.iter().filter(|index| index.unique) {
13789 let mut params = vec![json_param_to_sql(
13790 values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
13791 )?];
13792 for name in &index.columns {
13793 let column = table
13794 .columns
13795 .iter()
13796 .find(|column| column.name == *name)
13797 .ok_or("sync.local_corrupt")?;
13798 let cell = json_to_column_value(column, values.get(name))?;
13799 params.push(owned_sql_value(RowParam::Cell(&cell))?);
13800 }
13801 let sql = format!(
13802 "SELECT * FROM {source} WHERE {} != ? AND {}",
13803 quote_ident(&table.primary_key),
13804 index
13805 .columns
13806 .iter()
13807 .map(|column| format!("{} = ?", quote_ident(column)))
13808 .collect::<Vec<_>>()
13809 .join(" AND ")
13810 );
13811 let mut statement = self
13812 .conn
13813 .prepare_cached(&sql)
13814 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13815 let records = statement
13816 .query_map(rusqlite::params_from_iter(params), |record| {
13817 let values = table
13818 .columns
13819 .iter()
13820 .enumerate()
13821 .map(|(index, column)| {
13822 let value = sql_ref_to_json(column, record.get_ref(index)?);
13823 json_to_column_value(column, Some(&value)).map_err(|message| {
13824 rusqlite::Error::ToSqlConversionFailure(message.into())
13825 })
13826 })
13827 .collect::<Result<Row, _>>()?;
13828 Ok((values, record.get::<_, i64>(table.columns.len())?))
13829 })
13830 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13831 for record in records {
13832 let (row, version) =
13833 record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13834 let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
13835 conflicts.push(RetainedUniqueConflict {
13836 index: index.name.clone(),
13837 columns: index.columns.clone(),
13838 row_id,
13839 server_row: table
13840 .columns
13841 .iter()
13842 .zip(&row)
13843 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
13844 .collect(),
13845 server_version: version,
13846 });
13847 }
13848 }
13849 Ok(conflicts)
13850 }
13851
13852 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
13853 if let Some((commit, initial)) = self
13854 .failed_commits
13855 .iter()
13856 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
13857 {
13858 let mut rows = Vec::new();
13859 for operation in &commit.ops {
13860 let table = self
13861 .schema
13862 .table(&operation.table)
13863 .ok_or_else(|| "sync.unknown_table".to_owned())?;
13864 let key =
13865 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
13866 .map_err(|_| "sync.local_corrupt".to_owned())?;
13867 let base = self.stored_row(table, &operation.row_id, true)?;
13868 rows.push(RetainedCommitRow {
13869 table: operation.table.clone(),
13870 row_id: operation.row_id.clone(),
13871 unique_conflicts: if operation.upsert {
13872 self.unique_conflicts(
13873 table,
13874 initial
13875 .get(&key)
13876 .and_then(Value::as_object)
13877 .or(operation.values.as_ref())
13878 .ok_or("sync.local_corrupt")?,
13879 true,
13880 )?
13881 } else {
13882 Vec::new()
13883 },
13884 local_row: if operation.upsert {
13885 initial
13886 .get(&key)
13887 .and_then(Value::as_object)
13888 .cloned()
13889 .or_else(|| operation.values.clone())
13890 } else {
13891 None
13892 },
13893 server_row: base.as_ref().map(|(values, _)| {
13894 table
13895 .columns
13896 .iter()
13897 .zip(values)
13898 .map(|(column, value)| {
13899 (column.name.clone(), column_value_to_json(value))
13900 })
13901 .collect()
13902 }),
13903 server_version: base.map(|(_, version)| version),
13904 });
13905 }
13906 outcome.retained_rows = Some(rows);
13907 }
13908 Ok(outcome)
13909 }
13910
13911 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
13912 let row = self
13913 .conn
13914 .query_row(
13915 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
13916 resolution, resolved_at_ms, replacement_client_commit_id
13917 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
13918 rusqlite::params![client_commit_id],
13919 |row| {
13920 Ok(StoredCommitOutcomeRow {
13921 sequence: row.get(0)?,
13922 client_commit_id: row.get(1)?,
13923 status: row.get(2)?,
13924 recorded_at_ms: row.get(3)?,
13925 results_json: row.get(4)?,
13926 operations_json: row.get(5)?,
13927 resolution: row.get(6)?,
13928 resolved_at_ms: row.get(7)?,
13929 replacement_client_commit_id: row.get(8)?,
13930 })
13931 },
13932 )
13933 .optional()
13934 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13935 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
13936 .transpose()
13937 }
13938
13939 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
13940 if query.limit == Some(0) {
13941 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
13942 }
13943 let mut sql = String::from(
13944 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
13945 resolution, resolved_at_ms, replacement_client_commit_id
13946 FROM _syncular_commit_outcomes",
13947 );
13948 if query.active_only {
13949 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
13950 }
13951 sql.push_str(" ORDER BY seq DESC");
13952 if let Some(limit) = query.limit {
13953 sql.push_str(&format!(" LIMIT {limit}"));
13954 }
13955 let mut stmt = self
13956 .conn
13957 .prepare(&sql)
13958 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13959 let rows = stmt
13960 .query_map([], |row| {
13961 Ok(StoredCommitOutcomeRow {
13962 sequence: row.get(0)?,
13963 client_commit_id: row.get(1)?,
13964 status: row.get(2)?,
13965 recorded_at_ms: row.get(3)?,
13966 results_json: row.get(4)?,
13967 operations_json: row.get(5)?,
13968 resolution: row.get(6)?,
13969 resolved_at_ms: row.get(7)?,
13970 replacement_client_commit_id: row.get(8)?,
13971 })
13972 })
13973 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13974 let mut outcomes = Vec::new();
13975 for row in rows {
13976 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
13977 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
13978 )?)?);
13979 }
13980 Ok(outcomes)
13981 }
13982
13983 fn prune_commit_outcomes(&self) -> Result<(), String> {
13984 #[cfg(test)]
13985 self.outcome_prune_count
13986 .set(self.outcome_prune_count.get() + 1);
13987 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
13988 let count = self
13989 .conn
13990 .query_row(
13991 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
13992 [],
13993 |row| row.get::<_, i64>(0),
13994 )
13995 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13996 as usize;
13997 let excess = count.saturating_sub(max_entries);
13998 if excess == 0 {
13999 return Ok(());
14000 }
14001 let mut stmt = self
14002 .conn
14003 .prepare(
14004 "SELECT seq FROM _syncular_commit_outcomes
14005 WHERE status IN ('applied', 'cached') OR resolution != 'active'
14006 ORDER BY seq ASC LIMIT ?1",
14007 )
14008 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14009 let rows = stmt
14010 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
14011 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14012 let sequences = rows
14013 .collect::<Result<Vec<_>, _>>()
14014 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14015 drop(stmt);
14016 for sequence in sequences {
14017 self.conn
14018 .execute(
14019 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
14020 rusqlite::params![sequence],
14021 )
14022 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14023 }
14024 Ok(())
14025 }
14026
14027 pub fn subscribe(
14030 &mut self,
14031 id: String,
14032 table: String,
14033 scopes: Vec<(String, Vec<String>)>,
14034 params: Option<String>,
14035 ) -> Result<(), String> {
14036 if self.schema.table(&table).is_none() {
14037 return Err(format!("unknown table {table:?}"));
14038 }
14039 let registered_at = self
14040 .subs
14041 .iter()
14042 .position(|subscription| subscription.id == id);
14043 if let Some(index) = registered_at {
14044 let existing = &self.subs[index];
14045 let same_intent = existing.table == table
14046 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
14047 && existing.params == params;
14048 if !same_intent {
14049 return Err(
14050 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
14051 .to_owned(),
14052 );
14053 }
14054 if existing.state != SubState::Revoked {
14059 return Ok(());
14060 }
14061 }
14062 let sub = Subscription {
14063 id: id.clone(),
14064 table,
14065 requested: scopes,
14066 params,
14067 cursor: -1,
14068 bootstrap_state: None,
14069 state: SubState::Active,
14070 reason_code: None,
14071 effective: None,
14072 synced_once: false,
14073 };
14074 self.persist_sub(&sub)?;
14075 match registered_at {
14076 Some(index) => self.subs[index] = sub,
14077 None => self.subs.push(sub),
14078 }
14079 if registered_at.is_some() {
14083 self.set_sync_needed(true, true);
14084 }
14085 Ok(())
14086 }
14087
14088 pub fn subscribe_many(&mut self, requests: Vec<SubscriptionRequest>) -> Result<(), String> {
14094 let prior_subs = self.subs.clone();
14095 self.begin_observation("syncular_subscribe")?;
14096 let registered = (|| {
14097 for request in requests {
14098 self.subscribe(request.id, request.table, request.scopes, request.params)?;
14099 }
14100 self.conn
14101 .execute_batch("RELEASE syncular_subscribe")
14102 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14103 })();
14104 if let Err(error) = registered {
14105 self.rollback_observation("syncular_subscribe");
14106 self.subs = prior_subs;
14107 return Err(error);
14108 }
14109 Ok(())
14110 }
14111
14112 pub fn unsubscribe(&mut self, id: &str) {
14113 if self.subs.iter().any(|s| s.id == id) {
14114 self.cancel_sync_round();
14115 }
14116 self.subs.retain(|s| s.id != id);
14117 let _ = self.conn.execute(
14118 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
14119 rusqlite::params![id],
14120 );
14121 }
14122
14123 pub fn set_window(
14131 &mut self,
14132 base: &WindowBase,
14133 units: &[String],
14134 ) -> Result<CommandEffects, String> {
14135 let table = self
14136 .schema
14137 .table(&base.table)
14138 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
14139 if table.scope_column(&base.variable).is_none() {
14140 return Err(format!(
14141 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
14142 base.table, base.variable
14143 ));
14144 }
14145 let base_key = window_base_key(base);
14146 let wanted: std::collections::HashSet<&String> = units.iter().collect();
14147 let live = self.load_window_units(&base_key);
14148 self.begin_observation("syncular_window")?;
14149 let mut batch = ChangeAccumulator::default();
14150 let mut changed = false;
14151
14152 let prior_subs = self.subs.clone();
14153 let widened = (|| {
14154 for unit in units {
14156 if live.iter().any(|(u, _)| u == unit) {
14157 continue;
14158 }
14159 let sub_id = derive_sub_id(base, unit);
14160 self.delete_pending_evict(&sub_id)?;
14161 self.insert_window_unit(&base_key, unit, &sub_id)?;
14162 self.subscribe(
14163 sub_id,
14164 base.table.clone(),
14165 unit_scopes(base, unit),
14166 base.params.clone(),
14167 )?;
14168 batch.window(&base_key, &base.table, unit);
14169 changed = true;
14170 }
14171
14172 self.finish_observation("syncular_window", batch)
14173 })();
14174 if let Err(error) = widened {
14175 self.rollback_observation("syncular_window");
14176 self.subs = prior_subs;
14177 return Err(error);
14178 }
14179 for (unit, sub_id) in live {
14180 if wanted.contains(&unit) {
14181 continue;
14182 }
14183 self.rebuild_overlay_if_dirty()?;
14184 let prior_subs = self.subs.clone();
14185 self.begin_observation("syncular_window")?;
14186 let effective = self
14187 .subs
14188 .iter()
14189 .find(|sub| sub.id == sub_id)
14190 .and_then(|sub| sub.effective.clone())
14191 .unwrap_or_else(|| unit_scopes(base, &unit));
14192 let mut batch = ChangeAccumulator::default();
14193 self.record_scope_map(&mut batch, &base.table, &effective);
14194 batch.window(&base_key, &base.table, &unit);
14195 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
14196 let remaining = match result {
14197 Ok(remaining) => remaining,
14198 Err(error) => {
14199 self.rollback_observation("syncular_window");
14200 self.subs = prior_subs;
14201 return Err(error);
14202 }
14203 };
14204 if let Err(error) = self.finish_observation("syncular_window", batch) {
14205 self.rollback_observation("syncular_window");
14206 self.subs = prior_subs;
14207 return Err(error);
14208 }
14209 if remaining {
14210 self.drain_pending_evictions()?;
14211 }
14212 changed = true;
14213 }
14214 Ok(if changed {
14215 CommandEffects::interactive()
14216 } else {
14217 CommandEffects::none()
14218 })
14219 }
14220
14221 pub fn window_state(&self, base: &WindowBase) -> WindowState {
14226 let mut units = Vec::new();
14227 let mut pending = Vec::new();
14228 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
14229 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
14230 Some(sub) => {
14231 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
14232 }
14233 None => true,
14234 };
14235 if is_pending {
14236 pending.push(unit.clone());
14237 }
14238 units.push(unit);
14239 }
14240 WindowState { units, pending }
14241 }
14242
14243 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
14244 let mut stmt = match self
14245 .conn
14246 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
14247 {
14248 Ok(stmt) => stmt,
14249 Err(_) => return Vec::new(),
14250 };
14251 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
14252 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
14253 });
14254 match rows {
14255 Ok(rows) => rows.filter_map(Result::ok).collect(),
14256 Err(_) => Vec::new(),
14257 }
14258 }
14259
14260 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
14261 let mut stmt = match self.conn.prepare(
14262 "SELECT windows.base, windows.unit, subscriptions.tbl
14263 FROM _syncular_windows AS windows
14264 JOIN _syncular_subscriptions AS subscriptions
14265 ON subscriptions.id = windows.sub_id
14266 ORDER BY windows.base, windows.unit",
14267 ) {
14268 Ok(stmt) => stmt,
14269 Err(_) => return Vec::new(),
14270 };
14271 let rows = stmt.query_map([], |row| {
14272 Ok((
14273 row.get::<_, String>(0)?,
14274 row.get::<_, String>(1)?,
14275 row.get::<_, String>(2)?,
14276 ))
14277 });
14278 match rows {
14279 Ok(rows) => rows.filter_map(Result::ok).collect(),
14280 Err(_) => Vec::new(),
14281 }
14282 }
14283
14284 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
14285 self.conn
14286 .query_row(
14287 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
14288 rusqlite::params![sub_id],
14289 |row| Ok((row.get(0)?, row.get(1)?)),
14290 )
14291 .ok()
14292 }
14293
14294 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
14295 self.conn
14296 .execute(
14297 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
14298 rusqlite::params![base_key, unit, sub_id],
14299 )
14300 .map(|_| ())
14301 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14302 }
14303
14304 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
14305 self.conn
14306 .execute(
14307 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
14308 rusqlite::params![base_key, unit],
14309 )
14310 .map(|_| ())
14311 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14312 }
14313
14314 fn evict_unit(
14321 &mut self,
14322 base_key: &str,
14323 base: &WindowBase,
14324 unit: &str,
14325 sub_id: &str,
14326 ) -> Result<bool, String> {
14327 let effective = self
14328 .subs
14329 .iter()
14330 .find(|s| s.id == sub_id)
14331 .and_then(|s| s.effective.clone())
14332 .unwrap_or_else(|| unit_scopes(base, unit));
14333 let pinned = self.pinned_row_ids(&base.table);
14334 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
14335 self.delete_window_unit(base_key, unit)?;
14336 self.conn
14337 .execute(
14338 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
14339 [sub_id],
14340 )
14341 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14342 self.subs.retain(|sub| sub.id != sub_id);
14343 if deferred {
14344 self.save_pending_evict(sub_id, &base.table, &effective)?;
14345 } else {
14346 self.delete_pending_evict(sub_id)?;
14347 }
14348 Ok(remaining)
14349 }
14350
14351 fn evict_scope_rows(
14355 &self,
14356 table_name: &str,
14357 effective: &[(String, Vec<String>)],
14358 pinned: &std::collections::HashSet<String>,
14359 ) -> Result<(bool, bool), String> {
14360 if effective.is_empty() {
14361 return Ok((false, false));
14362 }
14363 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
14364 let mut clauses = Vec::new();
14365 let mut params: Vec<SqlValue> = Vec::new();
14366 for (variable, values) in effective {
14367 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
14368 if values.is_empty() {
14369 return Ok((false, false));
14370 }
14371 let holes = values
14372 .iter()
14373 .map(|v| {
14374 params.push(SqlValue::Text(v.clone()));
14375 "?"
14376 })
14377 .collect::<Vec<_>>()
14378 .join(", ");
14379 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
14380 }
14381 let scope = clauses.join(" AND ");
14382 let scope_params = params.len();
14383 let pk = quote_ident(&table.primary_key);
14384 if !pinned.is_empty() {
14385 let holes = pinned
14386 .iter()
14387 .map(|id| {
14388 params.push(SqlValue::Text(id.clone()));
14389 "?"
14390 })
14391 .collect::<Vec<_>>()
14392 .join(", ");
14393 clauses.push(format!("{pk} NOT IN ({holes})"));
14394 }
14395 let predicate = clauses.join(" AND ");
14396 let base = base_table(table_name);
14397 let visible = visible_table(table_name);
14398 let mut ids: Vec<SqlValue> = Vec::new();
14399 for source in [&base, &visible] {
14400 let mut stmt = self
14401 .conn
14402 .prepare_cached(&format!(
14403 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
14404 ))
14405 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14406 ids = stmt
14407 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
14408 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14409 .collect::<Result<_, _>>()
14410 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14411 if !ids.is_empty() {
14412 break;
14413 }
14414 }
14415 if !ids.is_empty() {
14416 for id in &ids {
14417 self.conn
14418 .execute(
14419 "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
14420 rusqlite::params![table_name, id],
14421 )
14422 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14423 }
14424 let holes = vec!["?"; ids.len()].join(", ");
14425 for source in [&base, &visible] {
14426 self.conn
14427 .execute(
14428 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
14429 rusqlite::params_from_iter(&ids),
14430 )
14431 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14432 }
14433 }
14434 let mut remaining = false;
14435 let mut deferred = false;
14436 for source in [&base, &visible] {
14437 remaining |= self
14438 .conn
14439 .query_row(
14440 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
14441 rusqlite::params_from_iter(¶ms),
14442 |row| row.get::<_, bool>(0),
14443 )
14444 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14445 deferred |= self
14446 .conn
14447 .query_row(
14448 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
14449 rusqlite::params_from_iter(¶ms[..scope_params]),
14450 |row| row.get::<_, bool>(0),
14451 )
14452 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14453 }
14454 Ok((remaining, deferred))
14455 }
14456
14457 fn drain_pending_evictions(&mut self) -> Result<(), String> {
14459 let pending = self.load_pending_evictions()?;
14460 if pending.is_empty() {
14461 return Ok(());
14462 }
14463 self.rebuild_overlay_if_dirty()?;
14464 for (sub_id, table_name, effective) in pending {
14465 if self.schema.table(&table_name).is_none() {
14466 self.delete_pending_evict(&sub_id)?;
14467 continue;
14468 }
14469 loop {
14470 std::thread::yield_now();
14471 self.begin_observation("syncular_evict")?;
14472 let pinned = self.pinned_row_ids(&table_name);
14473 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
14474 let (remaining, deferred) = match result {
14475 Ok(result) => result,
14476 Err(error) => {
14477 self.rollback_observation("syncular_evict");
14478 return Err(error);
14479 }
14480 };
14481 if !deferred {
14482 if let Err(error) = self.delete_pending_evict(&sub_id) {
14483 self.rollback_observation("syncular_evict");
14484 return Err(error);
14485 }
14486 }
14487 let mut batch = ChangeAccumulator::default();
14488 self.record_scope_map(&mut batch, &table_name, &effective);
14489 if let Err(error) = self.finish_observation("syncular_evict", batch) {
14490 self.rollback_observation("syncular_evict");
14491 return Err(error);
14492 }
14493 if !remaining {
14494 break;
14495 }
14496 }
14497 }
14498 Ok(())
14499 }
14500
14501 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
14504 let mut pinned = std::collections::HashSet::new();
14505 for commit in self
14506 .outbox
14507 .iter()
14508 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
14509 {
14510 for op in &commit.ops {
14511 if op.table == table {
14512 pinned.insert(op.row_id.clone());
14513 }
14514 }
14515 }
14516 pinned
14517 }
14518
14519 fn save_pending_evict(
14520 &self,
14521 sub_id: &str,
14522 table: &str,
14523 effective: &[(String, Vec<String>)],
14524 ) -> Result<(), String> {
14525 self.conn.execute(
14526 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
14527 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
14528 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14529 }
14530
14531 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
14532 self.conn
14533 .execute(
14534 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
14535 rusqlite::params![sub_id],
14536 )
14537 .map(|_| ())
14538 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14539 }
14540
14541 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
14542 let mut stmt = self
14543 .conn
14544 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
14545 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14546 let rows = stmt
14547 .query_map([], |row| {
14548 Ok((
14549 row.get::<_, String>(0)?,
14550 row.get::<_, String>(1)?,
14551 row.get::<_, String>(2)?,
14552 ))
14553 })
14554 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14555 rows.map(|row| {
14556 let (sub_id, table, json) =
14557 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14558 let value: Value = serde_json::from_str(&json)
14559 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
14560 let effective = json_to_scope_map(&value)
14561 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
14562 Ok((sub_id, table, effective))
14563 })
14564 .collect()
14565 }
14566
14567 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
14569 self.retain_failed_commits = enabled;
14570 for (commit, _) in &self.failed_commits {
14571 for operation in &commit.ops {
14572 self.overlay_dirty.table(&operation.table);
14573 }
14574 }
14575 self.failed_commits = self.load_failed_commits()?;
14576 for (commit, _) in &self.failed_commits {
14577 for operation in &commit.ops {
14578 self.overlay_dirty.table(&operation.table);
14579 }
14580 }
14581 Ok(())
14582 }
14583
14584 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
14585 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
14586 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14587 let rows = statement
14588 .query_map([], |row| {
14589 Ok((
14590 row.get::<_, String>(0)?,
14591 row.get::<_, String>(1)?,
14592 row.get::<_, String>(2)?,
14593 ))
14594 })
14595 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14596 rows.map(|row| {
14597 let (id, operations, initial) =
14598 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14599 let operations: Vec<CommitOperation> =
14600 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
14601 let initial =
14602 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
14603 Ok((
14604 OutboxCommit {
14605 client_commit_id: id,
14606 ops: operations
14607 .into_iter()
14608 .map(|op| OutboxOp {
14609 upsert: op.op == "upsert",
14610 table: op.table,
14611 row_id: op.row_id,
14612 base_version: op.base_version,
14613 values: op.values,
14614 })
14615 .collect(),
14616 },
14617 initial,
14618 ))
14619 })
14620 .collect()
14621 }
14622
14623 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
14624 let mut initial = Map::new();
14625 for operation in operations {
14626 let table = self
14627 .schema
14628 .table(&operation.table)
14629 .ok_or_else(|| "sync.unknown_table".to_owned())?;
14630 let initial_row = match self.stored_row(table, &operation.row_id, true)? {
14631 Some(row) => Some(row),
14632 None => self.visible_row(table, &operation.row_id)?,
14633 };
14634 if let Some((values, _)) = initial_row {
14635 let mut intended: Map<String, Value> = table
14636 .columns
14637 .iter()
14638 .zip(values.iter())
14639 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14640 .collect();
14641 if let Some(patch) = &operation.values {
14642 intended.extend(patch.clone());
14643 }
14644 initial.insert(
14645 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14646 .map_err(|_| "sync.local_corrupt".to_owned())?,
14647 Value::Object(intended),
14648 );
14649 }
14650 }
14651 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
14652 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
14653 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
14654 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14655 self.failed_commits.push((
14656 OutboxCommit {
14657 client_commit_id: id.to_owned(),
14658 ops: operations.to_vec(),
14659 },
14660 initial,
14661 ));
14662 Ok(())
14663 }
14664
14665 fn drop_failed_commits(
14666 &mut self,
14667 matches: impl Fn(&str, &Map<String, Value>) -> bool,
14668 ) -> Result<Vec<OutboxCommit>, String> {
14669 let mut doomed = self.drop_acknowledged_rows(&matches)?;
14670 for (commit, initial) in &self.failed_commits {
14671 for operation in &commit.ops {
14672 let key =
14673 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14674 .map_err(|_| "sync.local_corrupt".to_owned())?;
14675 if initial
14676 .get(&key)
14677 .and_then(Value::as_object)
14678 .is_some_and(|values| matches(&operation.table, values))
14679 {
14680 doomed.push(commit.clone());
14681 break;
14682 }
14683 }
14684 }
14685 for commit in &doomed {
14686 self.conn
14687 .execute(
14688 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
14689 rusqlite::params![commit.client_commit_id],
14690 )
14691 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14692 }
14693 for commit in &doomed {
14694 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))?;
14695 }
14696 self.conflicts.retain(|record| {
14697 !doomed
14698 .iter()
14699 .any(|commit| commit.client_commit_id == record.client_commit_id)
14700 });
14701 self.rejections.retain(|record| {
14702 !doomed
14703 .iter()
14704 .any(|commit| commit.client_commit_id == record.client_commit_id)
14705 });
14706 self.failed_commits.retain(|(commit, _)| {
14707 !doomed
14708 .iter()
14709 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
14710 });
14711 for commit in &doomed {
14712 for operation in &commit.ops {
14713 self.overlay_dirty.table(&operation.table);
14714 }
14715 }
14716 Ok(doomed)
14717 }
14718
14719 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
14723 let prior = self.storage_failure.borrow_mut().take();
14724 let outcome = (|| -> Result<String, String> {
14725 if mutations.is_empty() {
14726 return Err(
14727 "sync.invalid_request: a commit must contain at least one operation (§6.1)"
14728 .to_owned(),
14729 );
14730 }
14731 let mut ops = Vec::with_capacity(mutations.len());
14732 for mutation in mutations {
14733 match mutation {
14734 Mutation::Patch {
14735 table,
14736 values,
14737 base_version,
14738 } => {
14739 let schema_table = self
14740 .schema
14741 .table(&table)
14742 .ok_or_else(|| "sync.unknown_table".to_owned())?;
14743 let values = normalize_values_casing(schema_table, values)?;
14744 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
14745 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
14746 }
14747 Mutation::Upsert {
14748 table,
14749 values,
14750 base_version,
14751 } => {
14752 let schema_table = self.schema.table(&table).ok_or_else(|| {
14753 format!("sync.unknown_table: unknown table {table:?}")
14754 })?;
14755 let values = normalize_values_casing(schema_table, values)?;
14759 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
14760 let values = full_row_values(schema_table, values)?;
14763 self.validate_author_encode(schema_table, &row_id, &values)?;
14769 ops.push(OutboxOp {
14770 upsert: true,
14771 table,
14772 row_id,
14773 base_version,
14774 values: Some(values),
14775 });
14776 }
14777 Mutation::Delete {
14778 table,
14779 row_id,
14780 base_version,
14781 } => {
14782 if self.schema.table(&table).is_none() {
14783 return Err(format!("sync.unknown_table: unknown table {table:?}"));
14784 }
14785 ops.push(OutboxOp {
14786 upsert: false,
14787 table,
14788 row_id,
14789 base_version,
14790 values: None,
14791 });
14792 }
14793 }
14794 }
14795 self.record_outbox_commit(ops)
14796 })();
14797 self.finish_authoring(prior, outcome)
14798 }
14799
14800 fn finish_authoring(
14806 &self,
14807 prior: Option<QueryReadFailure>,
14808 outcome: Result<String, String>,
14809 ) -> Result<String, ClientError> {
14810 let fresh = self.storage_failure.borrow_mut().take();
14811 *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
14812 match outcome {
14813 Ok(id) => Ok(id),
14814 Err(message) => Err(fresh
14815 .map(ClientError::from)
14816 .unwrap_or_else(|| ClientError::from(message))),
14817 }
14818 }
14819
14820 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
14821 let commit = OutboxCommit {
14822 client_commit_id: uuid::Uuid::new_v4().to_string(),
14823 ops,
14824 };
14825 self.begin_observation("syncular_mutation")?;
14826 let mut batch = ChangeAccumulator::default();
14827 for op in &commit.ops {
14828 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
14829 if let Some(values) = &op.values {
14830 if let Some(table) = self.schema.table(&op.table) {
14831 for scope in &table.scope_variables {
14832 if let Some(Value::String(value)) = values.get(&scope.column) {
14833 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
14834 precise = true;
14835 }
14836 }
14837 }
14838 }
14839 if !precise {
14840 batch.table(&op.table);
14841 }
14842 }
14843 if let Err(error) = self.persist_outbox_insert(&commit) {
14844 self.rollback_observation("syncular_mutation");
14845 return Err(error);
14846 }
14847 let was_dirty = self.overlay_dirty.snapshot();
14848 if let Err(error) = self.rebuild_overlay_if_dirty() {
14853 self.rollback_observation("syncular_mutation");
14854 self.overlay_dirty.restore(was_dirty.clone());
14855 return Err(error);
14856 }
14857 if let Some(error) = commit
14858 .ops
14859 .iter()
14860 .find_map(|op| self.apply_outbox_op(op).err())
14861 {
14862 self.rollback_observation("syncular_mutation");
14863 self.overlay_dirty.restore(was_dirty.clone());
14864 return Err(error);
14865 }
14866 let id = commit.client_commit_id.clone();
14867 self.outbox.push(commit);
14868 batch.status = true;
14869 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
14870 self.rollback_observation("syncular_mutation");
14871 self.outbox.pop();
14872 self.overlay_dirty.restore(was_dirty.clone());
14873 return Err(error);
14874 }
14875 Ok(id)
14876 }
14877
14878 pub fn patch(
14884 &mut self,
14885 table: &str,
14886 row_id: &str,
14887 partial: Map<String, Value>,
14888 base_version: Option<i64>,
14889 ) -> Result<String, ClientError> {
14890 let prior = self.storage_failure.borrow_mut().take();
14891 let outcome = self.perform_patch(table, row_id, partial, base_version);
14892 self.finish_authoring(prior, outcome)
14893 }
14894
14895 fn perform_patch(
14898 &mut self,
14899 table: &str,
14900 row_id: &str,
14901 partial: Map<String, Value>,
14902 base_version: Option<i64>,
14903 ) -> Result<String, String> {
14904 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
14905 self.record_outbox_commit(vec![operation])
14906 }
14907
14908 fn prepare_patch(
14909 &self,
14910 table: &str,
14911 row_id: &str,
14912 partial: Map<String, Value>,
14913 base_version: Option<i64>,
14914 ) -> Result<OutboxOp, String> {
14915 let schema_table = self
14916 .schema
14917 .table(table)
14918 .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
14919 let partial = normalize_values_casing(schema_table, partial)?;
14920 if let Some(pk) = partial.get(&schema_table.primary_key) {
14921 if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
14922 return Err(format!(
14923 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
14924 ));
14925 }
14926 }
14927 let exists: bool = self
14930 .conn
14931 .query_row(
14932 &format!(
14933 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
14934 visible_table(table),
14935 row_id_predicate(schema_table)
14936 ),
14937 rusqlite::params![row_id],
14938 |row| row.get(0),
14939 )
14940 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14941 if !exists {
14942 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
14943 }
14944 for scope in &schema_table.scope_variables {
14954 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
14955 continue;
14956 }
14957 let Some(column) = schema_table
14958 .columns
14959 .iter()
14960 .find(|column| column.name == scope.column)
14961 else {
14962 continue;
14963 };
14964 let stored = self
14965 .conn
14966 .query_row(
14967 &format!(
14968 "SELECT {} FROM {} WHERE {}",
14969 quote_ident(&scope.column),
14970 visible_table(&schema_table.name),
14971 row_id_predicate(schema_table)
14972 ),
14973 rusqlite::params![row_id],
14974 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
14975 )
14976 .optional()
14977 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14978 let incoming = json_to_column_value(column, partial.get(&scope.column))
14983 .ok()
14984 .map(|value| column_value_to_json(&value));
14985 let equal = stored.as_ref().is_some_and(|stored| {
14989 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
14990 });
14991 if !equal {
14992 return Err(format!(
14993 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
14994 scope.column
14995 ));
14996 }
14997 }
14998 let mut values = partial;
14999 for scope in &schema_table.scope_variables {
15002 values.remove(&scope.column);
15003 }
15004 let pk_value = match schema_table.columns[schema_table.pk_index].ty {
15005 ColumnType::Integer => match row_id.parse::<i64>() {
15007 Ok(v)
15008 if v.to_string() == row_id
15009 && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
15010 .contains(&v) =>
15011 {
15012 Value::from(v)
15013 }
15014 _ => {
15015 return Err(format!(
15016 "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
15017 ))
15018 }
15019 },
15020 ColumnType::Boolean => match row_id {
15021 "true" => Value::Bool(true),
15022 "false" => Value::Bool(false),
15023 _ => {
15024 return Err(format!(
15025 "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
15026 ))
15027 }
15028 },
15029 _ => Value::from(row_id.to_owned()),
15030 };
15031 values.insert(schema_table.primary_key.clone(), pk_value);
15032 self.validate_author_encode(schema_table, row_id, &values)?;
15038 Ok(OutboxOp {
15039 upsert: true,
15040 table: table.to_owned(),
15041 row_id: row_id.to_owned(),
15042 base_version,
15043 values: Some(values),
15044 })
15045 }
15046
15047 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
15052 let selector = self.encryption.key_id_columns.get(&table.name)?;
15053 let index = table
15054 .columns
15055 .iter()
15056 .position(|column| &column.name == selector)?;
15057 let sql = format!(
15058 "SELECT {} FROM {} WHERE {}",
15059 quote_ident(selector),
15060 visible_table(&table.name),
15061 row_id_predicate(table)
15062 );
15063 let key: Option<String> = self
15064 .conn
15065 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
15066 .ok()?;
15067 let key = key.filter(|key| !key.is_empty())?;
15068 let mut fallback: Row = vec![None; table.columns.len()];
15069 fallback[index] = Some(ColumnValue::String(key));
15070 Some(fallback)
15071 }
15072
15073 fn validate_author_encode(
15078 &self,
15079 table: &TableSchema,
15080 row_id: &str,
15081 values: &Map<String, Value>,
15082 ) -> Result<(), String> {
15083 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
15084 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
15085 result => result.map(|_| ()),
15086 }
15087 }
15088
15089 fn drop_unencodable_outbox(
15103 &mut self,
15104 with_encryption: bool,
15105 batch: Option<&mut ChangeAccumulator>,
15106 ) -> Result<bool, String> {
15107 let failures = self
15108 .outbox
15109 .iter()
15110 .filter_map(|commit| {
15111 commit
15112 .ops
15113 .iter()
15114 .find_map(|op| {
15115 let values = op.values.as_ref().filter(|_| op.upsert)?;
15116 let table = self.schema.table(&op.table)?;
15117 let fallback = self.stored_key_fallback(table, &op.row_id);
15118 match encode_sparse_row_json(
15119 table,
15120 &op.row_id,
15121 values,
15122 &self.encryption,
15123 fallback.as_ref(),
15124 ) {
15125 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
15126 with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
15127 }
15128 Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
15133 LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
15134 OUTBOX_INCOMPATIBLE_CODE,
15135 )),
15136 _ => None,
15137 }
15138 })
15139 .map(|(error, code)| (commit.clone(), error, code))
15140 })
15141 .collect::<Vec<_>>();
15142 if failures.is_empty() {
15143 return Ok(false);
15144 }
15145 let prior_outbox = self.outbox.clone();
15151 let prior_rejections = self.rejections.clone();
15152 let prior_failed_commits = self.failed_commits.clone();
15156 let prior_changes = self.change_queue.len();
15157 let prior_intents = self.sync_intent_queue.len();
15158 let prior_last_change = self.last_change.clone();
15159 let prior_overlay_dirty = self.overlay_dirty.snapshot();
15160 let owns_observation = batch.is_none();
15161 let owns_transaction = self.conn.is_autocommit();
15162 let mut domains = ChangeAccumulator::default();
15163 if owns_observation {
15164 self.begin_observation("syncular_value_recovery")?;
15165 }
15166 let dropped = (|| -> Result<(), String> {
15167 for (commit, message, code) in failures {
15168 if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
15169 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
15170 }
15171 let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
15172 let rejection = RejectionRecord {
15173 client_commit_id: commit.client_commit_id.clone(),
15174 op_index: 0,
15175 code: code.to_owned(),
15176 message,
15177 retryable: false,
15178 details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
15179 field_paths: None,
15180 reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
15181 required_action: None,
15182 references: None,
15183 }),
15184 operation: representable
15185 .then(|| commit.ops.first())
15186 .flatten()
15187 .map(CommitOperation::from),
15188 };
15189 self.persist_commit_outcome(
15190 &commit.client_commit_id,
15191 CommitOutcomeStatus::Rejected,
15192 &[CommitOperationOutcome::Error {
15193 rejection: rejection.clone(),
15194 }],
15195 representable.then_some(commit.ops.as_slice()),
15196 )?;
15197 self.delete_outbox_persisted(&commit.client_commit_id)?;
15198 self.outbox
15199 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
15200 self.rejections.push(rejection);
15201 for operation in &commit.ops {
15202 domains.table(operation.table.as_str());
15203 }
15204 }
15205 domains.status = true;
15206 domains.rejections = true;
15207 domains.outcomes = true;
15208 self.prune_commit_outcomes()?;
15209 self.overlay_dirty.set(true);
15211 self.rebuild_overlay()?;
15212 Ok(())
15213 })();
15214 let result = match batch {
15215 Some(batch) => {
15216 if dropped.is_ok() {
15217 for table in domains.tables.keys() {
15218 batch.table(table.as_str());
15219 }
15220 batch.status |= domains.status;
15221 batch.rejections |= domains.rejections;
15222 batch.outcomes |= domains.outcomes;
15223 }
15224 dropped
15225 }
15226 None => {
15227 dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
15228 }
15229 };
15230 if let Err(error) = result {
15231 if owns_observation {
15232 if owns_transaction {
15233 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
15234 let mut failure = self.storage_failure.borrow_mut();
15235 let failure =
15236 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
15237 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
15238 }
15239 } else {
15240 self.rollback_observation("syncular_value_recovery");
15241 }
15242 }
15243 self.outbox = prior_outbox;
15244 self.rejections = prior_rejections;
15245 self.failed_commits = prior_failed_commits;
15246 self.change_queue.truncate(prior_changes);
15247 self.sync_intent_queue.truncate(prior_intents);
15248 self.last_change = prior_last_change;
15249 self.overlay_dirty.restore(prior_overlay_dirty);
15250 return Err(error);
15251 }
15252 Ok(true)
15253 }
15254
15255 pub fn pending_commit_ids(&self) -> Vec<String> {
15256 self.outbox
15257 .iter()
15258 .map(|c| c.client_commit_id.clone())
15259 .collect()
15260 }
15261
15262 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
15266 let mut payloads = Vec::new();
15267 for commit in &self.outbox {
15268 for op in &commit.ops {
15269 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
15270 continue;
15271 };
15272 let Some(table) = self.schema.table(&op.table) else {
15273 continue;
15274 };
15275 let fallback = self.stored_key_fallback(table, &op.row_id);
15276 if let Ok(payload) = encode_sparse_row_json(
15277 table,
15278 &op.row_id,
15279 values,
15280 &self.encryption,
15281 fallback.as_ref(),
15282 ) {
15283 payloads.push(payload);
15284 }
15285 }
15286 }
15287 payloads
15288 }
15289
15290 pub fn conflicts(&self) -> &[ConflictRecord] {
15291 &self.conflicts
15292 }
15293
15294 pub fn rejections(&self) -> &[RejectionRecord] {
15295 &self.rejections
15296 }
15297
15298 pub fn resolve_commit_outcome(
15299 &mut self,
15300 input: ResolveCommitOutcomeInput,
15301 ) -> Result<CommitOutcome, String> {
15302 let current = self
15303 .commit_outcome(&input.client_commit_id)?
15304 .ok_or_else(|| {
15305 format!(
15306 "sync.outcome_not_found: no durable outcome exists for {:?}",
15307 input.client_commit_id
15308 )
15309 })?;
15310 if current.resolution != CommitOutcomeResolution::Active {
15311 return Ok(current);
15312 }
15313 if input.resolution == CommitOutcomeResolution::Active {
15314 return Err("sync.invalid_request: resolution must leave active state".to_owned());
15315 }
15316 match input.resolution {
15317 CommitOutcomeResolution::Superseded => {
15318 let replacement = input
15319 .replacement_client_commit_id
15320 .as_deref()
15321 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
15322 .ok_or_else(|| {
15323 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
15324 .to_owned()
15325 })?;
15326 let _ = replacement;
15327 }
15328 _ if input.replacement_client_commit_id.is_some() => {
15329 return Err(
15330 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
15331 .to_owned(),
15332 );
15333 }
15334 _ => {}
15335 }
15336 let allowed = match current.status {
15337 CommitOutcomeStatus::Conflict => matches!(
15338 input.resolution,
15339 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
15340 ),
15341 CommitOutcomeStatus::Rejected => {
15342 matches!(
15343 input.resolution,
15344 CommitOutcomeResolution::Superseded
15345 | CommitOutcomeResolution::ResolvedKeepServer
15346 )
15347 }
15348 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
15349 input.resolution == CommitOutcomeResolution::Dismissed
15350 }
15351 };
15352 if !allowed {
15353 return Err(format!(
15354 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
15355 input.resolution, current.status
15356 ));
15357 }
15358
15359 self.begin_observation("syncular_outcome_resolution")?;
15360 let previous_failed = self.failed_commits.clone();
15361 let previous_conflicts = self.conflicts.clone();
15362 let previous_rejections = self.rejections.clone();
15363 let result = (|| {
15364 self.conn
15365 .execute(
15366 "UPDATE _syncular_commit_outcomes
15367 SET resolution = ?1, resolved_at_ms = ?2,
15368 replacement_client_commit_id = ?3
15369 WHERE client_commit_id = ?4 AND resolution = 'active'",
15370 rusqlite::params![
15371 Self::outcome_resolution_name(input.resolution),
15372 self.clock_now_ms(),
15373 input.replacement_client_commit_id,
15374 input.client_commit_id
15375 ],
15376 )
15377 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15378 let resolved = self
15379 .commit_outcome(¤t.client_commit_id)?
15380 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
15381 self.conn
15382 .execute(
15383 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
15384 rusqlite::params![input.client_commit_id],
15385 )
15386 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15387 for (commit, _) in &self.failed_commits {
15388 if commit.client_commit_id == input.client_commit_id {
15389 for operation in &commit.ops {
15390 self.overlay_dirty.table(&operation.table);
15391 }
15392 }
15393 }
15394 self.failed_commits
15395 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
15396 self.prune_commit_outcomes()?;
15397 Ok(resolved)
15398 })();
15399 let resolved = match result {
15400 Ok(outcome) => outcome,
15401 Err(error) => {
15402 self.rollback_observation("syncular_outcome_resolution");
15403 self.failed_commits = previous_failed;
15404 self.conflicts = previous_conflicts;
15405 self.rejections = previous_rejections;
15406 return Err(error);
15407 }
15408 };
15409 self.conflicts
15410 .retain(|record| record.client_commit_id != current.client_commit_id);
15411 self.rejections
15412 .retain(|record| record.client_commit_id != current.client_commit_id);
15413 let mut batch = ChangeAccumulator {
15414 conflicts: current.status == CommitOutcomeStatus::Conflict,
15415 rejections: current.status == CommitOutcomeStatus::Rejected,
15416 outcomes: true,
15417 ..ChangeAccumulator::default()
15418 };
15419 if let Some(operations) = ¤t.operations {
15420 for operation in operations {
15421 batch.table(&operation.table);
15422 }
15423 }
15424 if let Err(error) = self
15425 .rebuild_overlay_if_dirty()
15426 .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
15427 {
15428 self.rollback_observation("syncular_outcome_resolution");
15429 self.failed_commits = previous_failed;
15430 self.conflicts = previous_conflicts;
15431 self.rejections = previous_rejections;
15432 return Err(error);
15433 }
15434 Ok(resolved)
15435 }
15436
15437 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
15438 self.schema_floor.as_ref()
15439 }
15440
15441 pub fn lease_state(&self) -> Option<&LeaseState> {
15443 self.lease_state.as_ref()
15444 }
15445
15446 fn record_lease_error(&mut self, code: &str) {
15449 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
15450 return;
15451 }
15452 let mut next = self.lease_state.clone().unwrap_or_default();
15453 next.error_code = Some(code.to_owned());
15454 self.set_lease_state(Some(next));
15455 }
15456
15457 fn set_lease_state(&mut self, next: Option<LeaseState>) {
15458 if self.lease_state == next {
15459 return;
15460 }
15461 if self.begin_observation("syncular_lease").is_err() {
15462 return;
15463 }
15464 self.lease_state = next;
15465 if let Some(lease) = &self.lease_state {
15466 if let Ok(json) = serde_json::to_string(lease) {
15467 self.set_meta(LEASE_STATE_KEY, &json);
15468 }
15469 } else {
15470 self.delete_meta(LEASE_STATE_KEY);
15471 }
15472 let batch = ChangeAccumulator {
15473 status: true,
15474 ..ChangeAccumulator::default()
15475 };
15476 if self.finish_observation("syncular_lease", batch).is_err() {
15477 self.rollback_observation("syncular_lease");
15478 }
15479 }
15480
15481 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
15482 if self.schema_floor == next {
15483 return;
15484 }
15485 if self.begin_observation("syncular_schema_floor").is_err() {
15486 return;
15487 }
15488 self.schema_floor = next;
15489 self.stopped = self.schema_floor.is_some();
15490 if let Some(floor) = &self.schema_floor {
15491 if let Ok(json) = serde_json::to_string(floor) {
15492 self.set_meta(SCHEMA_FLOOR_KEY, &json);
15493 }
15494 } else {
15495 self.delete_meta(SCHEMA_FLOOR_KEY);
15496 }
15497 let batch = ChangeAccumulator {
15498 status: true,
15499 ..ChangeAccumulator::default()
15500 };
15501 if self
15502 .finish_observation("syncular_schema_floor", batch)
15503 .is_err()
15504 {
15505 self.rollback_observation("syncular_schema_floor");
15506 }
15507 }
15508
15509 fn set_upgrading(&mut self, value: bool) {
15510 if self.upgrading == value {
15511 return;
15512 }
15513 if self.begin_observation("syncular_upgrading").is_err() {
15514 return;
15515 }
15516 self.upgrading = value;
15517 let batch = ChangeAccumulator {
15518 status: true,
15519 ..ChangeAccumulator::default()
15520 };
15521 if self
15522 .finish_observation("syncular_upgrading", batch)
15523 .is_err()
15524 {
15525 self.rollback_observation("syncular_upgrading");
15526 }
15527 }
15528
15529 pub fn sync_needed(&self) -> bool {
15530 self.sync_needed
15531 }
15532
15533 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
15534 let sub = self.subs.iter().find(|s| s.id == id)?;
15535 Some(SubscriptionStateView {
15536 id: sub.id.clone(),
15537 table: sub.table.clone(),
15538 status: sub.state.name().to_owned(),
15539 cursor: sub.cursor,
15540 has_resume_token: sub.bootstrap_state.is_some(),
15541 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
15542 reason_code: sub.reason_code.clone(),
15543 })
15544 }
15545
15546 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
15547 let schema_table = self
15548 .schema
15549 .table(table)
15550 .ok_or_else(|| format!("unknown table {table:?}"))?;
15551 let sql = format!(
15552 "SELECT * FROM {} ORDER BY {} ASC",
15553 visible_table(table),
15554 quote_ident(&schema_table.primary_key)
15555 );
15556 let mut stmt = self
15557 .conn
15558 .prepare(&sql)
15559 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15560 let mut rows = stmt
15561 .query([])
15562 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15563 let mut out = Vec::new();
15564 while let Some(row) = rows
15565 .next()
15566 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15567 {
15568 let mut values = Map::new();
15569 for (i, column) in schema_table.columns.iter().enumerate() {
15570 let value = row
15571 .get_ref(i)
15572 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15573 values.insert(column.name.clone(), sql_ref_to_json(column, value));
15574 }
15575 let version: i64 = row
15576 .get(schema_table.columns.len())
15577 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15578 let row_id = match values.get(&schema_table.primary_key) {
15579 Some(Value::String(s)) => s.clone(),
15580 Some(Value::Number(n)) => n.to_string(),
15581 Some(Value::Bool(b)) => b.to_string(),
15582 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
15583 };
15584 out.push(RowState {
15585 row_id,
15586 version,
15587 values,
15588 });
15589 }
15590 Ok(out)
15591 }
15592
15593 #[cfg(feature = "crdt-yjs")]
15609 fn crdt_column_bytes(
15610 &self,
15611 table: &str,
15612 row_id: &str,
15613 column: &str,
15614 ) -> Result<Option<Vec<u8>>, String> {
15615 let schema_table = self
15616 .schema
15617 .table(table)
15618 .ok_or_else(|| format!("unknown table {table:?}"))?;
15619 let col = schema_table
15620 .columns
15621 .iter()
15622 .find(|c| c.name == column)
15623 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
15624 if col.ty != ColumnType::Crdt {
15625 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
15626 }
15627 let sql = format!(
15628 "SELECT {} FROM {} WHERE {}",
15629 quote_ident(column),
15630 visible_table(table),
15631 row_id_predicate(schema_table)
15632 );
15633 let bytes: Option<Vec<u8>> = self
15634 .conn
15635 .prepare_cached(&sql)
15636 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15637 .query_row(rusqlite::params![row_id], |row| {
15638 row.get::<_, Option<Vec<u8>>>(0)
15639 })
15640 .map_err(|e| match e {
15641 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
15642 other => Self::sqlite_failure(&self.storage_failure, other),
15643 })?;
15644 Ok(bytes)
15645 }
15646
15647 #[cfg(feature = "crdt-yjs")]
15651 pub fn crdt_text(
15652 &self,
15653 table: &str,
15654 row_id: &str,
15655 column: &str,
15656 name: &str,
15657 ) -> Result<String, String> {
15658 let bytes = self
15659 .crdt_column_bytes(table, row_id, column)?
15660 .unwrap_or_default();
15661 crate::crdt::text(&bytes, name)
15662 }
15663
15664 #[cfg(feature = "crdt-yjs")]
15668 pub fn crdt_insert_text(
15669 &mut self,
15670 table: &str,
15671 row_id: &str,
15672 column: &str,
15673 name: &str,
15674 index: u32,
15675 value: &str,
15676 ) -> Result<String, ClientError> {
15677 let prior = self.storage_failure.borrow_mut().take();
15678 let outcome = (|| -> Result<String, String> {
15679 let current = self
15680 .crdt_column_bytes(table, row_id, column)?
15681 .unwrap_or_default();
15682 let update = crate::crdt::insert_text(¤t, name, index, value)?;
15683 self.perform_crdt_push_update(table, row_id, column, &update)
15684 })();
15685 self.finish_authoring(prior, outcome)
15686 }
15687
15688 #[cfg(feature = "crdt-yjs")]
15692 pub fn crdt_delete_text(
15693 &mut self,
15694 table: &str,
15695 row_id: &str,
15696 column: &str,
15697 name: &str,
15698 index: u32,
15699 len: u32,
15700 ) -> Result<String, ClientError> {
15701 let prior = self.storage_failure.borrow_mut().take();
15702 let outcome = (|| -> Result<String, String> {
15703 let current = self
15704 .crdt_column_bytes(table, row_id, column)?
15705 .unwrap_or_default();
15706 let update = crate::crdt::delete_text(¤t, name, index, len)?;
15707 self.perform_crdt_push_update(table, row_id, column, &update)
15708 })();
15709 self.finish_authoring(prior, outcome)
15710 }
15711
15712 #[cfg(feature = "crdt-yjs")]
15717 pub fn crdt_apply_update(
15718 &mut self,
15719 table: &str,
15720 row_id: &str,
15721 column: &str,
15722 update: &[u8],
15723 ) -> Result<String, ClientError> {
15724 let prior = self.storage_failure.borrow_mut().take();
15725 let outcome = (|| -> Result<String, String> {
15726 let current = self
15727 .crdt_column_bytes(table, row_id, column)?
15728 .unwrap_or_default();
15729 let next = crate::crdt::apply_update(¤t, update)?;
15730 self.perform_crdt_push_update(table, row_id, column, &next)
15731 })();
15732 self.finish_authoring(prior, outcome)
15733 }
15734
15735 #[cfg(feature = "crdt-yjs")]
15742 fn perform_crdt_push_update(
15743 &mut self,
15744 table: &str,
15745 row_id: &str,
15746 column: &str,
15747 crdt_bytes: &[u8],
15748 ) -> Result<String, String> {
15749 let mut bytes_obj = Map::new();
15750 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
15751 let mut partial = Map::new();
15752 partial.insert(column.to_owned(), Value::Object(bytes_obj));
15753 self.perform_patch(table, row_id, partial, None)
15754 }
15755
15756 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
15771 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
15772 }
15773
15774 #[cfg(feature = "bench-internals")]
15776 #[doc(hidden)]
15777 pub fn benchmark_connection(&mut self) -> &mut Connection {
15778 &mut self.conn
15779 }
15780
15781 #[cfg(feature = "bench-internals")]
15783 #[doc(hidden)]
15784 pub fn benchmark_phases(
15785 &self,
15786 enabled: Option<bool>,
15787 reset: bool,
15788 ) -> Result<Option<Value>, String> {
15789 self.benchmark_phases.configure(enabled, reset)?;
15790 Ok(self.benchmark_phases.snapshot())
15791 }
15792
15793 pub fn authority_snapshot(&self) -> Result<Value, String> {
15795 validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
15796 if self.limits.authority_reads.is_empty() {
15797 return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
15798 }
15799 self.conn
15800 .execute_batch("SAVEPOINT authority_snapshot")
15801 .map_err(|e| e.to_string())?;
15802 let result = (|| {
15803 let revision = self
15804 .get_meta(LOCAL_REVISION_KEY)
15805 .ok_or("sync.local_corrupt: authority revision is missing")?;
15806 let parsed_revision = revision
15807 .parse::<u64>()
15808 .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
15809 if parsed_revision.to_string() != revision {
15810 return Err("sync.local_corrupt: authority revision is invalid".into());
15811 }
15812 let mut tables = Vec::new();
15813 for read in &self.limits.authority_reads {
15814 let table = self
15815 .schema
15816 .table(&read.table)
15817 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
15818 let scalar = |column: &str, value: &Value| -> Result<String, String> {
15819 let ty = table
15820 .columns
15821 .iter()
15822 .find(|c| c.name == column)
15823 .expect("validated authority column")
15824 .ty;
15825 if ty == ColumnType::Boolean {
15826 if let Some(value) = value.as_bool() {
15827 return Ok(value.to_string());
15828 }
15829 return match value.as_i64() {
15830 Some(0) => Ok("false".into()),
15831 Some(1) => Ok("true".into()),
15832 _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
15833 };
15834 }
15835 match value {
15836 Value::String(s) => Ok(s.clone()),
15837 Value::Number(n) => Ok(n.to_string()),
15838 Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
15839 .get("$bigint")
15840 .and_then(Value::as_str)
15841 .map(str::to_owned)
15842 .ok_or("sync.local_corrupt: authority integer is invalid".into()),
15843 _ => {
15844 Err("sync.local_corrupt: authority scalar is missing or invalid".into())
15845 }
15846 }
15847 };
15848 let matches = |row: &QueryRow| -> Result<bool, String> {
15849 for (variable, values) in &read.scopes {
15850 let column = table
15851 .scope_column(variable)
15852 .expect("validated authority scope");
15853 let value = row
15854 .get(column)
15855 .ok_or("sync.local_corrupt: authority scope column is missing")?;
15856 if value.is_null() || !values.contains(&scalar(column, value)?) {
15857 return Ok(false);
15858 }
15859 }
15860 Ok(true)
15861 };
15862 let selected = read
15863 .columns
15864 .iter()
15865 .map(|c| quote_ident(c))
15866 .collect::<Vec<_>>()
15867 .join(",");
15868 let mut intent = BTreeSet::new();
15869 let mut relevant = BTreeSet::new();
15870 for row in query_connection(&self.conn,
15871 "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=?",
15872 &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
15873 let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
15874 intent.insert(id.clone());
15875 if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
15876 let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
15877 if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
15878 }
15879 }
15880 let mut rows = BTreeMap::new();
15881 for full in [base_table(&read.table), visible_table(&read.table)] {
15882 for mut values in query_connection(
15883 &self.conn,
15884 &format!(
15885 "SELECT {selected},_syncular_version AS authority_version FROM {full}"
15886 ),
15887 &[],
15888 )
15889 .map_err(|e| e.to_string())?
15890 {
15891 if !matches(&values)? {
15892 continue;
15893 }
15894 let id = scalar(
15895 &table.primary_key,
15896 values
15897 .get(&table.primary_key)
15898 .expect("selected primary key"),
15899 )?;
15900 relevant.insert(id.clone());
15901 if full == base_table(&read.table) {
15902 let version = values
15903 .remove("authority_version")
15904 .expect("selected version");
15905 let version = version
15906 .as_i64()
15907 .ok_or("sync.local_corrupt: authority server version is invalid")?;
15908 if version >= 0 {
15909 rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
15910 }
15911 }
15912 }
15913 }
15914 intent.retain(|id| relevant.contains(id));
15915 let rows = rows.into_values().collect::<Vec<_>>();
15916 let mut persisted = Vec::new();
15917 for row in query_connection(
15918 &self.conn,
15919 "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
15920 &[Value::String(read.table.clone())],
15921 )
15922 .map_err(|e| e.message)?
15923 {
15924 let state: Value = serde_json::from_str(
15925 row.get("state_json")
15926 .and_then(Value::as_str)
15927 .ok_or("sync.local_corrupt: authority subscription state is missing")?,
15928 )
15929 .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
15930 let effective = state.get("effectiveScopes").and_then(Value::as_object);
15931 let status = state
15932 .get("status")
15933 .and_then(Value::as_str)
15934 .ok_or("sync.local_corrupt: authority subscription status is missing")?;
15935 let cursor = state
15936 .get("cursor")
15937 .and_then(Value::as_i64)
15938 .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
15939 let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
15940 && status == "active"
15941 && cursor >= 0
15942 && state.get("bootstrapState").is_none_or(Value::is_null)
15943 && state.get("params").is_none_or(Value::is_null);
15944 let exposed = effective.map(|s| {
15945 read.scopes
15946 .keys()
15947 .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
15948 .collect::<Map<_, _>>()
15949 });
15950 let requested = read
15951 .scopes
15952 .keys()
15953 .map(|v| {
15954 (
15955 v.clone(),
15956 state
15957 .get("requested")
15958 .and_then(|s| s.get(v))
15959 .cloned()
15960 .unwrap_or(json!([])),
15961 )
15962 })
15963 .collect::<Map<_, _>>();
15964 persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
15965 }
15966 fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
15967 match selectors.split_first() {
15968 None => !candidates.is_empty(),
15969 Some(((variable, values), rest)) => values.iter().all(|value| {
15970 covered(
15971 rest,
15972 &candidates
15973 .iter()
15974 .copied()
15975 .filter(|p| {
15976 p["effectiveScopes"][variable.as_str()]
15977 .as_array()
15978 .is_some_and(|held| {
15979 held.contains(&Value::String(value.clone()))
15980 || held.contains(&Value::String("*".into()))
15981 })
15982 })
15983 .collect::<Vec<_>>(),
15984 )
15985 }),
15986 }
15987 }
15988 let coverage = if covered(
15989 &read.scopes.iter().collect::<Vec<_>>(),
15990 &persisted
15991 .iter()
15992 .filter(|p| p["complete"] == true)
15993 .collect::<Vec<_>>(),
15994 ) {
15995 "complete"
15996 } else if persisted.iter().any(|p| {
15997 p["status"] == "active"
15998 && read.scopes.iter().all(|(v, values)| {
15999 values.iter().any(|value| {
16000 p["requestedScopes"][v].as_array().is_some_and(|held| {
16001 held.contains(&Value::String(value.clone()))
16002 || held.contains(&Value::String("*".into()))
16003 })
16004 })
16005 })
16006 }) {
16007 "pending"
16008 } else {
16009 "missing"
16010 };
16011 tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
16012 }
16013 Ok(
16014 json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
16015 )
16016 })();
16017 match result {
16018 Ok(snapshot) => {
16019 self.conn
16020 .execute_batch("RELEASE authority_snapshot")
16021 .map_err(|e| e.to_string())?;
16022 Ok(snapshot)
16023 }
16024 Err(error) => {
16025 self.conn
16026 .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
16027 .map_err(|e| format!("{error}; rollback failed: {e}"))?;
16028 Err(error)
16029 }
16030 }
16031 }
16032
16033 pub fn query_snapshot(
16037 &mut self,
16038 sql: &str,
16039 params: &[QueryValue],
16040 coverage: &[WindowCoverage],
16041 owner: Option<QueryOwner<'_>>,
16042 ) -> Result<QuerySnapshot, String> {
16043 let result = snapshot_connection(&self.conn, sql, params, coverage);
16044 if let Some(owner) = owner {
16045 self.record_query_read(owner, result.as_ref().err());
16046 }
16047 result.map_err(|failure| failure.to_string())
16048 }
16049
16050 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
16054 let position = self
16055 .query_failures
16056 .iter()
16057 .position(|entry| entry.id == owner.id);
16058 let Some(failure) = failure else {
16059 if let Some(index) = position {
16060 self.query_failures.remove(index);
16061 }
16062 return;
16063 };
16064 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
16065 .into_iter()
16066 .map(str::to_owned)
16067 .collect();
16068 let code = failure.code.unwrap_or("client.query_failed").to_owned();
16069 if position.is_some_and(|index| {
16070 let entry = &self.query_failures[index];
16071 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
16072 }) {
16073 return;
16074 }
16075 if let Some(index) = position {
16076 self.query_failures.remove(index);
16077 }
16078 self.query_failures.push(DiagnosticQueryFailure {
16079 id: owner.id.to_owned(),
16080 tables,
16081 code,
16082 sqlite_code: failure.sqlite_code,
16083 at_ms: self.clock_now_ms(),
16084 });
16085 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
16086 self.query_failures.remove(0);
16087 }
16088 }
16089
16090 fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
16093 #[cfg(feature = "bench-internals")]
16094 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
16095 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
16096 let max_commits = self.limits.max_push_commits_per_request;
16097 let max_ops = self
16098 .limits
16099 .max_push_operations_per_request
16100 .unwrap_or(PUSH_OPS_PER_REQUEST);
16101 let byte_cap = self.limits.max_push_request_bytes;
16102 let accept = self.limits.accept.unwrap_or(if url_capable {
16105 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
16106 } else {
16107 DEFAULT_ACCEPT
16108 });
16109 let header = Frame::ReqHeader {
16110 client_id: self.client_id.clone(),
16111 schema_version: self.schema.version,
16112 log_epoch: log_epoch.clone(),
16113 };
16114 let mut lead = vec![header];
16118 lead.extend(
16119 self.round_failure
16120 .iter()
16121 .map(|code| Frame::RoundFailure { code: code.clone() }),
16122 );
16123 let pull = Frame::PullHeader {
16124 limit_commits: self.limits.limit_commits.unwrap_or(0),
16125 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
16126 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
16127 accept,
16128 };
16129 let mut fresh = Vec::new();
16130 let mut sub_frames = Vec::new();
16131 for sub in &self.subs {
16132 if sub.state != SubState::Active {
16133 continue;
16134 }
16135 let mut scopes = sub.requested.clone();
16136 sort_scope_map(&mut scopes);
16137 sub_frames.push(Frame::Subscription {
16138 id: sub.id.clone(),
16139 table: sub.table.clone(),
16140 scopes,
16141 params: sub.params.clone().map(RawJson),
16142 cursor: sub.cursor,
16143 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
16144 });
16145 fresh.push((
16146 sub.id.clone(),
16147 sub.cursor < 0 && sub.bootstrap_state.is_none(),
16148 ));
16149 }
16150 let fixed_bytes = match byte_cap {
16153 Some(_) => {
16154 let mut frames = lead.clone();
16155 frames.push(pull.clone());
16156 frames.extend(sub_frames.iter().cloned());
16157 encode_message(&Message {
16158 wire_version: WIRE_VERSION,
16159 msg_kind: MsgKind::Request,
16160 frames,
16161 })
16162 .len()
16163 }
16164 None => 0,
16165 };
16166 if let Some(cap) = byte_cap {
16167 if fixed_bytes > cap {
16168 return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
16169 }
16170 }
16171 let base_bytes = match byte_cap {
16172 Some(_) => {
16173 let mut frames = lead.clone();
16174 frames.push(pull.clone());
16175 encode_message(&Message {
16176 wire_version: WIRE_VERSION,
16177 msg_kind: MsgKind::Request,
16178 frames,
16179 })
16180 .len()
16181 }
16182 None => 0,
16183 };
16184 let total_outbox = if log_epoch.is_some() {
16185 self.outbox.len()
16186 } else {
16187 0
16188 };
16189 let mut push_frames: Vec<Frame> = Vec::new();
16190 let mut pushed_ids = Vec::new();
16191 let mut ops_in_request = 0usize;
16192 let mut bytes_in_request = 0usize;
16193 let mut deferred_commits = 0usize;
16194 #[cfg(feature = "bench-internals")]
16195 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
16196 for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
16197 if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
16201 deferred_commits = total_outbox - index;
16202 break;
16203 }
16204 if ops_in_request + commit.ops.len() > max_ops {
16205 if push_frames.is_empty() {
16206 return Err(Self::push_capacity_outcome(
16207 "operations",
16208 max_ops,
16209 commit.ops.len(),
16210 Some(commit.client_commit_id.as_str()),
16211 ));
16212 }
16213 deferred_commits = total_outbox - index;
16214 break;
16215 }
16216 let operations = commit
16217 .ops
16218 .iter()
16219 .map(|op| {
16220 let payload = op.values.as_ref().and_then(|values| {
16221 let table = self.schema.table(&op.table)?;
16222 let fallback = self.stored_key_fallback(table, &op.row_id);
16231 encode_sparse_row_json(
16232 table,
16233 &op.row_id,
16234 values,
16235 &self.encryption,
16236 fallback.as_ref(),
16237 )
16238 .ok()
16239 });
16240 ssp2::model::Operation {
16241 table: op.table.clone(),
16242 row_id: op.row_id.clone(),
16243 op: if op.upsert { Op::Upsert } else { Op::Delete },
16244 base_version: op.base_version,
16245 payload,
16246 }
16247 })
16248 .collect();
16249 let frame = Frame::PushCommit {
16250 client_commit_id: commit.client_commit_id.clone(),
16251 operations,
16252 };
16253 let frame_bytes = match byte_cap {
16254 Some(_) => {
16255 let mut frames = lead.clone();
16256 frames.push(frame.clone());
16257 frames.push(pull.clone());
16258 encode_message(&Message {
16259 wire_version: WIRE_VERSION,
16260 msg_kind: MsgKind::Request,
16261 frames,
16262 })
16263 .len()
16264 - base_bytes
16265 }
16266 None => 0,
16267 };
16268 if let Some(cap) = byte_cap {
16272 if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
16273 deferred_commits = total_outbox - index;
16274 break;
16275 }
16276 }
16277 push_frames.push(frame);
16278 pushed_ids.push(commit.client_commit_id.clone());
16279 ops_in_request += commit.ops.len();
16280 bytes_in_request += frame_bytes;
16281 }
16282 #[cfg(feature = "bench-internals")]
16283 drop(outbox_phase);
16284 if let Some(cap) = byte_cap {
16285 if fixed_bytes + bytes_in_request > cap {
16286 let blocked = if push_frames.is_empty() {
16289 None
16290 } else {
16291 self.outbox.first().map(|c| c.client_commit_id.as_str())
16292 };
16293 return Err(Self::push_capacity_outcome(
16294 "bytes",
16295 cap,
16296 fixed_bytes + bytes_in_request,
16297 blocked,
16298 ));
16299 }
16300 }
16301 let mut frames = lead;
16302 frames.extend(push_frames);
16303 frames.push(pull);
16304 frames.extend(sub_frames);
16305 let message = Message {
16306 wire_version: WIRE_VERSION,
16307 msg_kind: MsgKind::Request,
16308 frames,
16309 };
16310 Ok((
16311 message,
16312 RequestMeta {
16313 pushed_ids,
16314 fresh,
16315 accept,
16316 deferred_commits,
16317 },
16318 ))
16319 }
16320
16321 fn push_capacity_outcome(
16323 kind: &str,
16324 limit: usize,
16325 size: usize,
16326 client_commit_id: Option<&str>,
16327 ) -> Box<SyncOutcome> {
16328 let mut details = Map::new();
16329 details.insert("kind".to_owned(), Value::from(kind));
16330 details.insert("limit".to_owned(), Value::from(limit as u64));
16331 details.insert("size".to_owned(), Value::from(size as u64));
16332 if let Some(id) = client_commit_id {
16333 details.insert("clientCommitId".to_owned(), Value::from(id));
16334 }
16335 Box::new(SyncOutcome::Failed {
16336 error_code: "client.push_request_too_large".into(),
16337 message: "the push request exceeds a configured capacity limit".into(),
16338 details: Some(Value::Object(details)),
16339 })
16340 }
16341
16342 pub fn progress(&self) -> ProgressObserver {
16346 self.progress.clone()
16347 }
16348
16349 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
16350 let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
16351 Ok(prepared) => prepared,
16352 Err(outcome) => return *outcome,
16353 };
16354 let mut next = prepared;
16359 loop {
16360 match self.apply_sync_round(next.exchange(transport)) {
16361 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
16362 crate::AppliedSyncRound::Complete {
16363 outcome, controls, ..
16364 } => {
16365 if self.transport_enabled {
16366 for text in controls {
16367 let _ = transport.realtime_send(&text);
16368 }
16369 } else {
16370 self.disconnect_realtime(transport);
16371 }
16372 return outcome;
16373 }
16374 }
16375 }
16376 }
16377
16378 fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
16379 if let SyncOutcome::Failed {
16380 error_code,
16381 message,
16382 details,
16383 } = &mut outcome
16384 {
16385 if let Some(failure) = self.storage_failure.borrow().as_ref() {
16386 let code = failure
16387 .code
16388 .expect("retained storage failures are classified");
16389 *error_code = code.to_owned();
16390 *message = match code {
16391 "client.storage_busy" => "local SQLite storage is busy",
16392 "client.storage_full" => "local SQLite storage is full",
16393 "client.storage_io" => "local SQLite storage I/O failed",
16394 "client.storage_corrupt" => "local SQLite storage is corrupt",
16395 _ => unreachable!("unrecognized classified storage failure"),
16396 }
16397 .to_owned();
16398 *details = failure.details();
16399 }
16400 }
16401 let retry_delay_ms = self.round_retry_delay_ms;
16402 self.progress.update(|p| match &outcome {
16403 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
16404 if report.failed.is_empty() =>
16405 {
16406 p.state = ProgressState::Complete
16407 }
16408 SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
16409 p.state = ProgressState::Failed;
16410 p.error_code = Some("sync.scope_revoked".into());
16411 }
16412 SyncOutcome::Failed { error_code, .. } => {
16413 p.state = ProgressState::Failed;
16414 p.error_code = Some(Self::diagnostic_code(error_code));
16415 p.retry_delay_ms = retry_delay_ms;
16416 }
16417 SyncOutcome::RealtimeUnavailable { .. } => {
16418 p.state = ProgressState::Failed;
16419 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
16420 p.retry_delay_ms = retry_delay_ms;
16421 }
16422 });
16423 let completed_at_ms = self.clock_now_ms();
16424 self.last_round = Some(match &outcome {
16425 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
16426 status: "succeeded".to_owned(),
16427 started_at_ms,
16428 completed_at_ms,
16429 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16430 counters: Some(DiagnosticRoundCounters {
16431 pushed: report.pushed,
16432 applied: report.applied.len(),
16433 rejected: report.rejected.len(),
16434 retryable: report.retryable.len(),
16435 conflicts: report.conflicts,
16436 commits_applied: report.commits_applied,
16437 segment_rows_applied: report.segment_rows_applied,
16438 bootstrapping: report.bootstrapping.len(),
16439 resets: report.resets.len(),
16440 revoked: report.revoked.len(),
16441 failed: report.failed.len(),
16442 deferred_commits: report.deferred_commits,
16443 }),
16444 error_code: None,
16445 },
16446 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
16447 status: "failed".to_owned(),
16448 started_at_ms,
16449 completed_at_ms,
16450 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16451 counters: None,
16452 error_code: Some(Self::diagnostic_code(error_code)),
16453 },
16454 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
16455 status: "failed".to_owned(),
16456 started_at_ms,
16457 completed_at_ms,
16458 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16459 counters: None,
16460 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
16461 },
16462 });
16463 outcome
16464 }
16465
16466 pub fn prepare_sync_round(
16469 &mut self,
16470 url_capable: bool,
16471 ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
16472 if !self.transport_enabled {
16473 return Err(Box::new(SyncOutcome::Failed {
16474 error_code: "sync.offline".into(),
16475 message: "transport gate is closed".into(),
16476 details: None,
16477 }));
16478 }
16479 if self.active_round.is_some() {
16480 return Err(Box::new(SyncOutcome::Failed {
16481 error_code: "client.round_in_progress".into(),
16482 message: "a sync round is already in flight".into(),
16483 details: None,
16484 }));
16485 }
16486 self.storage_failure.borrow_mut().take();
16487 self.progress.start();
16488 self.round_retry_delay_ms = None;
16489 let started_at_ms = self.clock_now_ms();
16490 let prepared = (|| {
16491 if self.stopped {
16492 return Err(Box::new(SyncOutcome::Ok(SyncReport {
16493 schema_floor: self.schema_floor.clone(),
16494 ..SyncReport::default()
16495 })));
16496 }
16497 if self.security_preflight {
16498 return Err(Box::new(SyncOutcome::Failed {
16499 error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
16500 message: "security preflight must complete before syncing".into(),
16501 details: None,
16502 }));
16503 }
16504 self.set_sync_needed(false, false);
16505 self.drain_pending_evictions()
16506 .and_then(|_| self.drop_incompatible_outbox())
16507 .and_then(|_| self.drop_unencodable_outbox(true, None))
16508 .map_err(|message| {
16509 Box::new(SyncOutcome::Failed {
16510 error_code: "storage.failed".into(),
16511 message,
16512 details: None,
16513 })
16514 })?;
16515 let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
16516 self.pending_blob_uploads().map_err(|e| {
16517 Box::new(SyncOutcome::Failed {
16518 error_code: e.code,
16519 message: e.message,
16520 details: e.details,
16521 })
16522 })?
16523 } else {
16524 Vec::new()
16525 };
16526 if self.realtime_state != RealtimeState::Connected
16527 && self.realtime_policy == RealtimePolicy::Required
16528 {
16529 let retry_delay_ms = self.schedule_background_retry();
16530 self.realtime_retry_delay_ms = Some(retry_delay_ms);
16531 return Err(Box::new(SyncOutcome::RealtimeUnavailable {
16532 state: self.realtime_state,
16533 reason_code: self.realtime_reason_code.clone(),
16534 retry_delay_ms,
16535 }));
16536 }
16537 let (message, meta) = self.build_request(url_capable)?;
16538 let id = uuid::Uuid::new_v4();
16539 self.active_round = Some(id);
16540 Ok(crate::PreparedSyncRound {
16541 id,
16542 started_at_ms,
16543 message,
16544 meta,
16545 uploads: uploads.into(),
16546 realtime: self.realtime_state == RealtimeState::Connected,
16547 scopes: self
16548 .subs
16549 .iter()
16550 .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
16551 .collect(),
16552 #[cfg(feature = "bench-internals")]
16553 benchmark_phases: self.benchmark_phases.clone(),
16554 progress: self.progress.clone(),
16555 fixed_now: self.now_ms,
16556 round_deadline_at: None,
16557 })
16558 })();
16559 prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
16560 }
16561
16562 pub fn cancel_sync_round(&mut self) {
16565 self.active_round = None;
16566 }
16567
16568 pub fn apply_sync_round(
16571 &mut self,
16572 completed: crate::CompletedSyncRound,
16573 ) -> crate::AppliedSyncRound {
16574 let prepared = completed.prepared;
16575 if self.active_round != Some(prepared.id) || self.security_preflight {
16576 return crate::AppliedSyncRound::Complete {
16577 outcome: SyncOutcome::Failed {
16578 error_code: "client.round_cancelled".into(),
16579 message: "sync round belongs to a released client context".into(),
16580 details: None,
16581 },
16582 controls: Vec::new(),
16583 more: false,
16584 bootstrap_advanced: false,
16585 };
16586 }
16587 self.storage_failure.borrow_mut().take();
16588 let (response, mut downloads, transport_failed) = match completed.exchange {
16589 crate::round::ExchangeResult::Upload { id, result } => {
16590 let result = result.and_then(|()| {
16591 self.conn
16592 .execute(
16593 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
16594 rusqlite::params![id],
16595 )
16596 .map(|_| ())
16597 .map_err(|error| {
16598 TransportError::new(
16599 "client.failed",
16600 Self::sqlite_failure(&self.storage_failure, error),
16601 )
16602 })
16603 });
16604 match result {
16605 Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
16606 Err(TransportError {
16607 code,
16608 message,
16609 details,
16610 }) => {
16611 self.active_round = None;
16612 if Self::retryable_failure_code(&code) {
16613 self.schedule_background_retry();
16614 }
16615 let outcome = self.finish_sync_round(
16616 prepared.started_at_ms,
16617 SyncOutcome::Failed {
16618 error_code: code,
16619 message,
16620 details,
16621 },
16622 );
16623 return crate::AppliedSyncRound::Complete {
16624 outcome,
16625 controls: Vec::new(),
16626 more: false,
16627 bootstrap_advanced: false,
16628 };
16629 }
16630 }
16631 }
16632 crate::round::ExchangeResult::Reply {
16633 response,
16634 downloads,
16635 transport_failed,
16636 } => (response, downloads, transport_failed),
16637 };
16638 self.active_round = None;
16639 let before = self
16640 .subs
16641 .iter()
16642 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
16643 .collect::<Vec<_>>();
16644 let outcome = (|| {
16645 let response = match response {
16646 Ok(response) => {
16647 self.round_failure = None;
16650 response
16651 }
16652 Err(TransportError {
16653 code,
16654 message,
16655 details,
16656 }) => {
16657 if ssp2::model::valid_round_failure_code(&code) {
16662 self.round_failure = Some(code.clone());
16663 }
16664 if transport_failed {
16665 if prepared.realtime {
16666 self.set_realtime_state(RealtimeState::Lost, Some(&code));
16667 }
16668 self.record_lease_error(&code);
16669 }
16670 if Self::retryable_failure_code(&code) {
16671 let delay = self.schedule_background_retry();
16672 if prepared.realtime && transport_failed {
16673 self.realtime_retry_delay_ms = Some(delay);
16674 }
16675 }
16676 return SyncOutcome::Failed {
16677 error_code: code,
16678 message,
16679 details,
16680 };
16681 }
16682 };
16683 let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
16684 if let SyncOutcome::Ok(report) = &mut outcome {
16685 report.deferred_commits = prepared.meta.deferred_commits;
16686 }
16687 match &outcome {
16688 SyncOutcome::Ok(_) => self.reset_background_retry(),
16689 SyncOutcome::Failed { error_code, .. }
16690 if Self::retryable_failure_code(error_code) =>
16691 {
16692 self.schedule_background_retry();
16693 }
16694 _ => {}
16695 }
16696 if prepared.meta.deferred_commits > 0 {
16697 self.set_sync_needed(true, true);
16698 }
16699 self.previous_version_lifetime_check();
16700 outcome
16701 })();
16702 let more = self.transport_enabled
16703 && matches!(&outcome, SyncOutcome::Ok(report)
16704 if report.schema_floor.is_none()
16705 && (!report.bootstrapping.is_empty()
16706 || report.commits_applied > 0
16707 || report.segment_rows_applied > 0
16708 || !report.resets.is_empty()
16709 || report.deferred_commits > 0
16710 || self.sync_needed));
16711 let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
16712 && before
16713 != self
16714 .subs
16715 .iter()
16716 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
16717 .collect::<Vec<_>>();
16718 crate::AppliedSyncRound::Complete {
16719 outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
16720 controls: if self.transport_enabled {
16721 downloads.controls
16722 } else {
16723 Vec::new()
16724 },
16725 more,
16726 bootstrap_advanced,
16727 }
16728 }
16729
16730 pub fn sync_until_idle(
16731 &mut self,
16732 transport: &mut dyn Transport,
16733 max_rounds: Option<u32>,
16734 ) -> SyncOutcome {
16735 if max_rounds == Some(0) {
16738 return SyncOutcome::Failed {
16739 details: None,
16740 error_code: "sync.invalid_request".into(),
16741 message: "maxRounds must be a positive integer".into(),
16742 };
16743 }
16744 let rounds = max_rounds.unwrap_or(20);
16745 let mut aggregate = SyncReport::default();
16746 let mut spent = 0;
16747 while spent < rounds {
16748 spent += 1;
16749 let before = self
16750 .subs
16751 .iter()
16752 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
16753 .collect::<Vec<_>>();
16754 match self.sync(transport) {
16755 SyncOutcome::Failed {
16756 error_code,
16757 message,
16758 details,
16759 } => {
16760 return SyncOutcome::Failed {
16761 details,
16762 error_code,
16763 message,
16764 };
16765 }
16766 outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
16767 SyncOutcome::BudgetExhausted(_) => {
16768 unreachable!("sync() never exhausts a round budget")
16769 }
16770 SyncOutcome::Ok(report) => {
16771 if max_rounds.is_none()
16772 && report.segment_rows_applied > 0
16773 && !report.bootstrapping.is_empty()
16774 && before
16775 != self
16776 .subs
16777 .iter()
16778 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
16779 .collect::<Vec<_>>()
16780 {
16781 spent = 0;
16782 }
16783 let stop = !self.transport_enabled() || report.schema_floor.is_some();
16786 aggregate.merge(&report);
16787 if stop {
16788 return SyncOutcome::Ok(aggregate);
16789 }
16790 let more = !report.bootstrapping.is_empty()
16796 || report.commits_applied > 0
16797 || report.segment_rows_applied > 0
16798 || !report.resets.is_empty()
16799 || report.deferred_commits > 0
16800 || self.sync_needed;
16801 if !more {
16802 return SyncOutcome::Ok(aggregate);
16803 }
16804 }
16805 }
16806 }
16807 SyncOutcome::BudgetExhausted(aggregate)
16810 }
16811
16812 fn process_response(
16813 &mut self,
16814 transport: &mut dyn Transport,
16815 response: Message,
16816 meta: &RequestMeta,
16817 ) -> SyncOutcome {
16818 #[cfg(feature = "bench-internals")]
16819 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
16820 let mut report = SyncReport {
16821 pushed: meta.pushed_ids.len() as u32,
16822 ..SyncReport::default()
16823 };
16824 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
16825 HashMap::new();
16826 let pushed_ids = meta
16827 .pushed_ids
16828 .iter()
16829 .map(String::as_str)
16830 .collect::<HashSet<_>>();
16831 let mut last_final_push_result_id: Option<String> = None;
16832 for frame in &response.frames {
16833 match frame {
16834 Frame::PushResultDetails {
16835 client_commit_id,
16836 entries,
16837 } => {
16838 let details = rejection_details_by_commit
16839 .entry(client_commit_id.clone())
16840 .or_default();
16841 for entry in entries {
16842 let parsed = match RejectionDetails::parse(&entry.details.0) {
16843 Ok(value) => value,
16844 Err(message) => {
16845 return SyncOutcome::Failed {
16846 details: None,
16847 error_code: "sync.invalid_request".to_owned(),
16848 message,
16849 };
16850 }
16851 };
16852 details.insert(entry.op_index, parsed);
16853 }
16854 }
16855 Frame::PushResult {
16856 client_commit_id,
16857 status,
16858 results,
16859 ..
16860 } if pushed_ids.contains(client_commit_id.as_str())
16861 && Self::push_result_is_final(*status, results) =>
16862 {
16863 last_final_push_result_id = Some(client_commit_id.clone());
16864 }
16865 _ => {}
16866 }
16867 }
16868 let mut frames = response.frames.into_iter().peekable();
16869 if response.wire_version < 2 {
16870 return SyncOutcome::Failed {
16871 details: None,
16872 error_code: "client.invalid_host_response".to_owned(),
16873 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
16874 };
16875 }
16876 match frames.next() {
16877 Some(Frame::RespHeader {
16878 required_schema_version,
16879 latest_schema_version,
16880 log_epoch,
16881 reset_required,
16882 }) => {
16883 let Some(log_epoch) = log_epoch else {
16884 return SyncOutcome::Failed {
16885 details: None,
16886 error_code: "client.invalid_host_response".to_owned(),
16887 message: "response header omits logEpoch".to_owned(),
16888 };
16889 };
16890 let Some(reset_required) = reset_required else {
16891 return SyncOutcome::Failed {
16892 details: None,
16893 error_code: "client.invalid_host_response".to_owned(),
16894 message: "response header omits resetRequired".to_owned(),
16895 };
16896 };
16897 if let Some(required) = required_schema_version {
16898 let floor = SchemaFloor {
16901 required_schema_version: Some(required),
16902 latest_schema_version,
16903 };
16904 self.set_schema_floor(Some(floor.clone()));
16905 report.schema_floor = Some(floor);
16906 return SyncOutcome::Ok(report);
16907 }
16908 if reset_required {
16909 if frames.next().is_some() {
16910 return SyncOutcome::Failed {
16911 details: None,
16912 error_code: "client.invalid_host_response".to_owned(),
16913 message: "log-epoch reset response contains body frames".to_owned(),
16914 };
16915 }
16916 match self.run_log_epoch_reset(&log_epoch) {
16917 Ok(resets) => {
16918 report.resets = resets;
16919 return SyncOutcome::Ok(report);
16920 }
16921 Err(message) => {
16922 return SyncOutcome::Failed {
16923 details: None,
16924 error_code: "sync.local_corrupt".to_owned(),
16925 message,
16926 };
16927 }
16928 }
16929 }
16930 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
16931 return SyncOutcome::Failed {
16932 details: None,
16933 error_code: "client.invalid_host_response".to_owned(),
16934 message: "server changed logEpoch without requiring a reset".to_owned(),
16935 };
16936 }
16937 }
16938 _ => {
16939 return SyncOutcome::Failed {
16940 details: None,
16941 error_code: "sync.invalid_request".to_owned(),
16942 message: "response does not start with RESP_HEADER".to_owned(),
16943 };
16944 }
16945 }
16946
16947 if let Err(message) = self.begin_coalesced_apply() {
16948 return SyncOutcome::Failed {
16949 details: None,
16950 error_code: "storage.failed".into(),
16951 message,
16952 };
16953 }
16954 let mut failure: Option<(String, String)> = None;
16955 while let Some(frame) = frames.next() {
16956 match frame {
16957 frame @ Frame::PushResult { .. } => {
16958 let successful = matches!(
16959 &frame,
16960 Frame::PushResult {
16961 status: PushStatus::Applied | PushStatus::Cached,
16962 ..
16963 }
16964 );
16965 let mut results = vec![frame];
16966 if successful {
16967 while matches!(
16968 frames.peek(),
16969 Some(Frame::PushResult {
16970 status: PushStatus::Applied | PushStatus::Cached,
16971 ..
16972 })
16973 ) {
16974 if let Some(next) = frames.next() {
16975 results.push(next);
16976 }
16977 }
16978 }
16979 if self
16980 .handle_push_results(
16981 &results,
16982 &pushed_ids,
16983 &rejection_details_by_commit,
16984 &mut report,
16985 last_final_push_result_id.as_deref(),
16986 )
16987 .is_err()
16988 {
16989 failure = Some((
16990 "client.outcome_persistence_failed".to_owned(),
16991 "local commit outcome could not be persisted".to_owned(),
16992 ));
16993 break;
16994 }
16995 }
16996 Frame::PushResultDetails { .. } => {}
16997 Frame::SubStart {
16998 id,
16999 status,
17000 reason_code,
17001 effective_scopes,
17002 bootstrap: _,
17003 } => {
17004 let mut body = Vec::new();
17005 let mut sub_end: Option<(i64, Option<String>)> = None;
17006 for inner in frames.by_ref() {
17007 match inner {
17008 Frame::SubEnd {
17009 next_cursor,
17010 bootstrap_state,
17011 } => {
17012 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
17013 break;
17014 }
17015 Frame::Unknown { .. } => {}
17016 other => body.push(other),
17017 }
17018 }
17019 if let Err(SectionError::Abort(code, message)) = self.process_section(
17020 transport,
17021 &id,
17022 status,
17023 &reason_code,
17024 effective_scopes,
17025 body,
17026 sub_end,
17027 meta,
17028 &mut report,
17029 ) {
17030 failure = Some((code, message));
17031 break;
17032 }
17033 }
17034 Frame::Lease {
17035 lease_id,
17036 expires_at_ms,
17037 } => {
17038 self.set_lease_state(Some(LeaseState {
17041 lease_id: Some(lease_id),
17042 expires_at_ms: Some(expires_at_ms),
17043 error_code: None,
17044 }));
17045 }
17046 Frame::Error { code, message, .. } => {
17047 failure = Some((code, message));
17050 break;
17051 }
17052 Frame::Unknown { .. } => {}
17053 _ => {
17054 failure = Some((
17055 "sync.invalid_request".to_owned(),
17056 "unexpected frame in response".to_owned(),
17057 ));
17058 break;
17059 }
17060 }
17061 }
17062
17063 let applied = match failure {
17064 Some(failure) => Err(failure),
17065 None => self
17070 .rebuild_overlay_if_dirty()
17071 .and_then(|()| self.drain_pending_evictions())
17072 .map_err(|message| ("storage.failed".to_owned(), message)),
17073 };
17074 if let Err(message) = self.finish_coalesced_apply() {
17077 return SyncOutcome::Failed {
17078 details: None,
17079 error_code: "storage.failed".into(),
17080 message,
17081 };
17082 }
17083 if let Err((error_code, message)) = applied {
17084 return SyncOutcome::Failed {
17085 details: None,
17086 error_code,
17087 message,
17088 };
17089 }
17090 self.ack_after_pull(transport);
17091 if self.upgrading && report.bootstrapping.is_empty() {
17094 self.set_upgrading(false);
17095 }
17096 SyncOutcome::Ok(report)
17097 }
17098
17099 fn handle_push_results(
17102 &mut self,
17103 frames: &[Frame],
17104 pushed_ids: &HashSet<&str>,
17105 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
17106 report: &mut SyncReport,
17107 last_final_push_result_id: Option<&str>,
17108 ) -> Result<(), String> {
17109 self.begin_observation("syncular_push_results")?;
17110 let previous_failed = self.failed_commits.clone();
17111 let previous_sync_needed = self.sync_needed;
17112 let conflict_count = self.conflicts.len();
17113 let rejection_count = self.rejections.len();
17114 let applied_count = report.applied.len();
17115 let rejected_count = report.rejected.len();
17116 let retryable_count = report.retryable.len();
17117 let was_dirty = self.overlay_dirty.snapshot();
17118 let mut removed = Vec::new();
17119 let mut batch = ChangeAccumulator::default();
17120 let persisted = (|| {
17121 for frame in frames {
17122 let Frame::PushResult {
17123 client_commit_id,
17124 status,
17125 results,
17126 commit_seq,
17127 } = frame
17128 else {
17129 return Err("unexpected acknowledgement frame".to_owned());
17130 };
17131 if !pushed_ids.contains(client_commit_id.as_str()) {
17132 continue;
17133 }
17134 let Some(index) = self
17135 .outbox
17136 .iter()
17137 .position(|commit| commit.client_commit_id == *client_commit_id)
17138 else {
17139 continue;
17140 };
17141 let status = *status;
17142 let rejection_details = rejection_details_by_commit.get(client_commit_id);
17143 let operations = self.outbox[index].ops.clone();
17144 match status {
17145 PushStatus::Applied | PushStatus::Cached => {
17146 let sequence =
17149 commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
17150 let affected: HashSet<_> = operations
17151 .iter()
17152 .map(|op| (op.table.as_str(), op.row_id.as_str()))
17153 .collect();
17154 let prior = self.acknowledged_operations(Some(&affected), None)?;
17155 for (op_index, operation) in operations.iter().enumerate() {
17156 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))
17157 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17158 if delivered {
17159 continue;
17160 }
17161 let table = self
17162 .schema
17163 .table(&operation.table)
17164 .ok_or_else(|| "sync.unknown_table".to_owned())?;
17165 let mut intended: Map<String, Value> = self
17166 .stored_row(table, &operation.row_id, true)?
17167 .map(|(row, _)| {
17168 table
17169 .columns
17170 .iter()
17171 .zip(&row)
17172 .map(|(column, value)| {
17173 (column.name.clone(), column_value_to_json(value))
17174 })
17175 .collect()
17176 })
17177 .unwrap_or_default();
17178 let mut scope_image = intended.clone();
17179 for op in prior
17180 .iter()
17181 .chain(
17182 self.failed_commits
17183 .iter()
17184 .flat_map(|(commit, _)| &commit.ops),
17185 )
17186 .chain(
17187 self.outbox
17188 .iter()
17189 .take(index)
17190 .flat_map(|commit| &commit.ops),
17191 )
17192 .chain(operations.iter().take(op_index + 1))
17193 {
17194 if op.table != operation.table || op.row_id != operation.row_id {
17195 continue;
17196 }
17197 if let Some(values) = &op.values {
17198 intended.extend(values.clone());
17199 } else {
17200 scope_image = intended.clone();
17201 intended.clear();
17202 }
17203 }
17204 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)",
17205 rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
17206 serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
17207 serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
17208 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17209 }
17210 let journal_results = results
17211 .iter()
17212 .map(|result| {
17213 let op_index = match result {
17214 OpResult::Applied { op_index }
17215 | OpResult::Conflict { op_index, .. }
17216 | OpResult::Error { op_index, .. } => *op_index,
17217 };
17218 CommitOperationOutcome::Applied { op_index }
17219 })
17220 .collect::<Vec<_>>();
17221 let outcome_status = if status == PushStatus::Applied {
17222 CommitOutcomeStatus::Applied
17223 } else {
17224 CommitOutcomeStatus::Cached
17225 };
17226 let persisted = self
17227 .persist_commit_outcome(
17228 client_commit_id,
17229 outcome_status,
17230 &journal_results,
17231 None,
17232 )
17233 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
17234 persisted?;
17235 for operation in &operations {
17236 self.overlay_dirty.table(&operation.table);
17237 }
17238 self.sync_needed = true;
17239 batch.status = true;
17240 batch.outcomes = true;
17241 }
17242 PushStatus::Rejected => {
17243 if results.iter().any(|result| {
17244 matches!(
17245 result,
17246 OpResult::Error {
17247 code,
17248 retryable: true,
17249 ..
17250 } if code == "sync.idempotency_cache_miss"
17251 )
17252 }) {
17253 report.retryable.push(client_commit_id.to_owned());
17256 continue;
17257 }
17258
17259 let mut journal_results = Vec::with_capacity(results.len());
17260 let mut conflicts = Vec::new();
17261 let mut rejections = Vec::new();
17262 for result in results {
17263 match result {
17264 OpResult::Applied { op_index } => {
17265 journal_results.push(CommitOperationOutcome::Applied {
17266 op_index: *op_index,
17267 });
17268 }
17269 OpResult::Conflict {
17270 op_index,
17271 code,
17272 message,
17273 server_version,
17274 server_row,
17275 conflict_columns: conflict_columns_bitmap,
17276 } => {
17277 let operation = operations
17278 .get(*op_index as usize)
17279 .map(CommitOperation::from);
17280 let (table, row_id) = operation
17281 .as_ref()
17282 .map(|op| (op.table.clone(), op.row_id.clone()))
17283 .unwrap_or_default();
17284 let server_row_json = self
17285 .schema
17286 .table(&table)
17287 .and_then(|t| {
17288 decode_row_bytes(t, server_row, &self.encryption)
17289 .ok()
17290 .map(|row| (t, row))
17291 })
17292 .map(|(t, row)| {
17293 let mut map = Map::new();
17294 for (i, column) in t.columns.iter().enumerate() {
17295 map.insert(
17296 column.name.clone(),
17297 column_value_to_json(
17298 row.get(i).unwrap_or(&None),
17299 ),
17300 );
17301 }
17302 map
17303 })
17304 .unwrap_or_default();
17305 let conflict_columns = self
17308 .schema
17309 .table(&table)
17310 .map(|t| {
17311 t.columns
17312 .iter()
17313 .enumerate()
17314 .filter(|(index, _)| {
17315 conflict_columns_bitmap
17316 .get(index / 8)
17317 .is_some_and(|byte| {
17318 byte & (1 << (index % 8)) != 0
17319 })
17320 })
17321 .map(|(_, column)| column.name.clone())
17322 .collect::<Vec<_>>()
17323 })
17324 .unwrap_or_default();
17325 let conflict = ConflictRecord {
17326 client_commit_id: client_commit_id.to_owned(),
17327 op_index: *op_index,
17328 table,
17329 row_id,
17330 code: code.clone(),
17331 message: message.clone(),
17332 server_version: *server_version,
17333 server_row: server_row_json,
17334 conflict_columns,
17335 operation,
17336 };
17337 journal_results.push(CommitOperationOutcome::Conflict {
17338 conflict: conflict.clone(),
17339 });
17340 conflicts.push(conflict);
17341 }
17342 OpResult::Error {
17343 op_index,
17344 code,
17345 message,
17346 retryable,
17347 } => {
17348 let rejection = RejectionRecord {
17349 client_commit_id: client_commit_id.to_owned(),
17350 op_index: *op_index,
17351 code: code.clone(),
17352 message: message.clone(),
17353 retryable: *retryable,
17354 details: rejection_details
17355 .and_then(|details| details.get(op_index))
17356 .cloned(),
17357 operation: operations
17358 .get(*op_index as usize)
17359 .map(CommitOperation::from),
17360 };
17361 journal_results.push(CommitOperationOutcome::Error {
17362 rejection: rejection.clone(),
17363 });
17364 rejections.push(rejection);
17365 }
17366 }
17367 }
17368 if self.retain_failed_commits {
17369 self.retain_failed_commit(client_commit_id, &operations)?;
17370 }
17371 let outcome_status = if conflicts.is_empty() {
17372 CommitOutcomeStatus::Rejected
17373 } else {
17374 CommitOutcomeStatus::Conflict
17375 };
17376 let persisted = self
17377 .persist_commit_outcome(
17378 client_commit_id,
17379 outcome_status,
17380 &journal_results,
17381 Some(&operations),
17382 )
17383 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
17384 persisted?;
17385 batch.conflicts = !conflicts.is_empty();
17386 batch.rejections = !rejections.is_empty();
17387 batch.status = true;
17388 batch.outcomes = true;
17389 self.conflicts.extend(conflicts);
17390 self.rejections.extend(rejections);
17391 for operation in &operations {
17392 self.overlay_dirty.table(&operation.table);
17393 }
17394 }
17395 }
17396 removed.push((index, self.outbox.remove(index)));
17397 if batch.status {
17398 for operation in &operations {
17399 if !self.record_row_scopes(
17400 &mut batch,
17401 &operation.table,
17402 &operation.row_id,
17403 false,
17404 ) {
17405 batch.table(&operation.table);
17406 }
17407 }
17408 }
17409 match status {
17410 PushStatus::Applied | PushStatus::Cached => {
17411 report.applied.push(client_commit_id.to_owned())
17412 }
17413 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
17414 }
17415 }
17416 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)) {
17417 self.prune_commit_outcomes()?;
17418 }
17419 self.rebuild_overlay_if_dirty()?;
17420 self.finish_observation("syncular_push_results", batch)
17421 })();
17422 if let Err(error) = persisted {
17423 self.rollback_observation("syncular_push_results");
17424 self.failed_commits = previous_failed;
17425 self.sync_needed = previous_sync_needed;
17426 for (index, commit) in removed.into_iter().rev() {
17427 self.outbox.insert(index, commit);
17428 }
17429 self.conflicts.truncate(conflict_count);
17430 self.rejections.truncate(rejection_count);
17431 self.overlay_dirty.restore(was_dirty.clone());
17432 report.applied.truncate(applied_count);
17433 report.rejected.truncate(rejected_count);
17434 report.retryable.truncate(retryable_count);
17435 return Err(error);
17436 }
17437 if !previous_sync_needed && self.sync_needed {
17438 self.sync_intent_queue.push_back(SyncIntent::Interactive);
17439 }
17440 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
17441 Ok(())
17442 }
17443
17444 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
17445 status != PushStatus::Rejected
17446 || !results.iter().any(|result| {
17447 matches!(
17448 result,
17449 OpResult::Error {
17450 code,
17451 retryable: true,
17452 ..
17453 } if code == "sync.idempotency_cache_miss"
17454 )
17455 })
17456 }
17457
17458 #[allow(clippy::too_many_arguments)]
17461 fn process_section(
17462 &mut self,
17463 transport: &mut dyn Transport,
17464 id: &str,
17465 status: SubStatus,
17466 reason_code: &str,
17467 effective_scopes: Vec<(String, Vec<String>)>,
17468 body: Vec<Frame>,
17469 sub_end: Option<(i64, Option<String>)>,
17470 meta: &RequestMeta,
17471 report: &mut SyncReport,
17472 ) -> Result<(), SectionError> {
17473 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
17474 return Ok(()); };
17476 if status != SubStatus::Active && sub_end.is_none() {
17477 return Err(SectionError::Abort(
17478 "sync.invalid_request".into(),
17479 "subscription section without SUB_END".into(),
17480 ));
17481 }
17482 match status {
17483 SubStatus::Revoked => {
17484 self.begin_observation("syncular_revocation")
17485 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
17486 let previous = self.subs[sub_index].clone();
17487 let was_dirty = self.overlay_dirty.snapshot();
17488 let previous_outbox = self.outbox.clone();
17489 let previous_failed = self.failed_commits.clone();
17490 let previous_rejections = self.rejections.clone();
17491 let previous_conflicts = self.conflicts.clone();
17492 let revoked_count = report.revoked.len();
17493 let failed_count = report.failed.len();
17494 let result = (|| {
17495 let mut batch = ChangeAccumulator::default();
17496 let registered = self.window_unit_by_sub_id(id);
17497 let (table, effective) = {
17499 let sub = &self.subs[sub_index];
17500 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17501 };
17502 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
17503 SectionError::Abort(
17504 "sync.unknown_table".to_owned(),
17505 "revoked scope references an unknown table".to_owned(),
17506 )
17507 })?;
17508 let dropped = self
17509 .drop_failed_commits(|name, values| {
17510 name == table
17511 && !effective.is_empty()
17512 && effective.iter().all(|(variable, allowed)| {
17513 scoped_table
17514 .scope_column(variable)
17515 .and_then(|column| values.get(column))
17516 .and_then(Value::as_str)
17517 .is_some_and(|value| {
17518 allowed.iter().any(|allowed| allowed == value)
17519 })
17520 })
17521 })
17522 .map_err(|message| {
17523 SectionError::Abort("storage.failed".to_owned(), message)
17524 })?;
17525 for commit in dropped {
17526 batch.conflicts = true;
17527 batch.rejections = true;
17528 batch.outcomes = true;
17529 for operation in commit.ops {
17530 batch.table(&operation.table);
17531 }
17532 }
17533 let purged = self.purge_scope_rows(&table, &effective);
17534 match purged {
17535 Ok(()) => {
17536 self.record_scope_map(&mut batch, &table, &effective);
17537 let sub = &mut self.subs[sub_index];
17538 sub.state = SubState::Revoked;
17539 sub.reason_code = Some(if reason_code.is_empty() {
17540 "sync.scope_revoked".to_owned()
17541 } else {
17542 reason_code.to_owned()
17543 });
17544 report.revoked.push(id.to_owned());
17545 let doomed_effective = effective;
17546 let sub_table = table;
17547 self.persist_sub(&self.subs[sub_index].clone())
17548 .map_err(|message| {
17549 SectionError::Abort("storage.failed".into(), message)
17550 })?;
17551 let dropped = self
17552 .drop_doomed_outbox(&sub_table, &doomed_effective)
17553 .map_err(|message| {
17554 SectionError::Abort("storage.failed".to_owned(), message)
17555 })?;
17556 if dropped {
17557 batch.status = true;
17558 batch.rejections = true;
17559 batch.outcomes = true;
17560 }
17561 self.delete_unreferenced_cached_blobs().map_err(|message| {
17564 SectionError::Abort("storage.failed".into(), message)
17565 })?;
17566 }
17567 Err(()) => {
17568 let sub = &mut self.subs[sub_index];
17571 sub.state = SubState::Failed;
17572 sub.reason_code = Some("sync.scope_revoked".to_owned());
17573 report.failed.push(id.to_owned());
17574 self.persist_sub(&self.subs[sub_index].clone())
17575 .map_err(|message| {
17576 SectionError::Abort("storage.failed".into(), message)
17577 })?;
17578 }
17579 }
17580 if let Some((base_key, unit)) = registered {
17581 batch.window(&base_key, &self.subs[sub_index].table, &unit);
17582 }
17583 self.rebuild_overlay_if_dirty()
17584 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17585 self.finish_observation("syncular_revocation", batch)
17586 .map_err(|message| {
17587 SectionError::Abort("storage.failed".to_owned(), message)
17588 })?;
17589 Ok(())
17590 })();
17591 if result.is_err() {
17592 self.rollback_observation("syncular_revocation");
17593 self.subs[sub_index] = previous;
17594 self.overlay_dirty.restore(was_dirty.clone());
17595 self.outbox = previous_outbox;
17596 self.failed_commits = previous_failed;
17597 self.rejections = previous_rejections;
17598 self.conflicts = previous_conflicts;
17599 report.revoked.truncate(revoked_count);
17600 report.failed.truncate(failed_count);
17601 }
17602 result
17603 }
17604 SubStatus::Reset => {
17605 self.begin_observation("syncular_reset")
17606 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
17607 let previous = self.subs[sub_index].clone();
17608 let was_dirty = self.overlay_dirty.snapshot();
17609 let reset_count = report.resets.len();
17610 let result = (|| {
17611 let mut batch = ChangeAccumulator::default();
17612 let registered = self.window_unit_by_sub_id(id);
17613 let sub = &mut self.subs[sub_index];
17616 sub.cursor = -1;
17617 sub.bootstrap_state = None;
17618 sub.reason_code = Some(if reason_code.is_empty() {
17619 "sync.cursor_expired".to_owned()
17620 } else {
17621 reason_code.to_owned()
17622 });
17623 report.resets.push(id.to_owned());
17624 self.persist_sub(&self.subs[sub_index].clone())
17625 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17626 if let Some((base_key, unit)) = registered {
17627 batch.window(&base_key, &self.subs[sub_index].table, &unit);
17628 }
17629 self.finish_observation("syncular_reset", batch)
17630 .map_err(|message| {
17631 SectionError::Abort("storage.failed".to_owned(), message)
17632 })?;
17633 Ok(())
17634 })();
17635 if result.is_err() {
17636 self.rollback_observation("syncular_reset");
17637 self.subs[sub_index] = previous;
17638 self.overlay_dirty.restore(was_dirty.clone());
17639 report.resets.truncate(reset_count);
17640 }
17641 result
17642 }
17643 SubStatus::Active => {
17644 let fresh = meta
17645 .fresh
17646 .iter()
17647 .find(|(fid, _)| fid == id)
17648 .map(|(_, fresh)| *fresh)
17649 .unwrap_or(false);
17650 let previous = self.subs[sub_index].clone();
17651 self.subs[sub_index].effective = Some(effective_scopes);
17652 let outcome = self
17653 .apply_section_body(transport, sub_index, body, fresh, meta, report)
17654 .and_then(|()| {
17655 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
17656 SectionError::Abort(
17657 "sync.invalid_request".into(),
17658 "subscription section without SUB_END".into(),
17659 )
17660 })?;
17661 self.apply_sub_end(sub_index, cursor, bootstrap_state)
17662 .map_err(|message| {
17663 SectionError::Abort("storage.failed".into(), message)
17664 })
17665 });
17666 match outcome {
17667 Ok(()) => {
17668 if self.subs[sub_index].bootstrap_state.is_some() {
17669 report.bootstrapping.push(id.to_owned());
17670 }
17671 Ok(())
17672 }
17673 Err(SectionError::FailClosed) => {
17674 self.subs[sub_index] = previous.clone();
17675 self.begin_observation("syncular_section_failure")
17676 .map_err(|message| {
17677 SectionError::Abort("storage.failed".into(), message)
17678 })?;
17679 let persisted = (|| {
17680 let mut batch = ChangeAccumulator::default();
17681 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
17682 batch.window(&base_key, &self.subs[sub_index].table, &unit);
17683 }
17684 self.subs[sub_index].state = SubState::Failed;
17685 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
17686 self.persist_sub(&self.subs[sub_index])?;
17687 self.finish_observation("syncular_section_failure", batch)
17688 })();
17689 if let Err(message) = persisted {
17690 self.rollback_observation("syncular_section_failure");
17691 self.subs[sub_index] = previous;
17692 return Err(SectionError::Abort("storage.failed".into(), message));
17693 }
17694 report.failed.push(id.to_owned());
17695 Ok(())
17696 }
17697 Err(error) => {
17698 self.subs[sub_index] = previous;
17701 Err(error)
17702 }
17703 }
17704 }
17705 }
17706 }
17707
17708 fn apply_sub_end(
17709 &mut self,
17710 sub_index: usize,
17711 cursor: i64,
17712 bootstrap_state: Option<String>,
17713 ) -> Result<(), String> {
17714 #[cfg(feature = "bench-internals")]
17715 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
17716 let previous = self.subs[sub_index].clone();
17717 self.begin_observation("syncular_sub_end")?;
17718 let persisted = (|| {
17719 let mut batch = ChangeAccumulator::default();
17720 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
17721 && cursor >= 0
17722 && bootstrap_state.is_none();
17723 if completed {
17724 let table = self
17725 .schema
17726 .table(&previous.table)
17727 .ok_or_else(|| "sync.unknown_table".to_owned())?;
17728 let effective = previous.effective.as_deref().unwrap_or(&[]);
17729 let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
17730 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17731 let rows = statement
17732 .query_map(rusqlite::params![previous.table, cursor], |row| {
17733 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
17734 })
17735 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17736 let mut covered = HashSet::new();
17737 for row in rows {
17738 let (id, intent) =
17739 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17740 let values: Map<String, Value> = serde_json::from_str(&intent)
17741 .map_err(|_| "sync.local_corrupt".to_owned())?;
17742 if !effective.is_empty()
17743 && effective.iter().all(|(variable, allowed)| {
17744 table
17745 .scope_column(variable)
17746 .and_then(|column| values.get(column))
17747 .is_some_and(|value| {
17748 allowed.contains(
17749 &value
17750 .as_str()
17751 .map_or_else(|| value.to_string(), str::to_owned),
17752 )
17753 })
17754 })
17755 {
17756 covered.insert(id);
17757 }
17758 }
17759 drop(statement);
17760 for id in covered {
17761 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
17762 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17763 self.overlay_dirty.table(&previous.table);
17764 batch.table(&previous.table);
17765 }
17766 self.rebuild_overlay_if_dirty()?;
17767 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
17768 batch.window(&base_key, &previous.table, &unit);
17769 }
17770 }
17771 let sub = &mut self.subs[sub_index];
17772 sub.cursor = cursor;
17773 sub.bootstrap_state = bootstrap_state;
17774 sub.reason_code = None;
17777 sub.synced_once = true;
17778 self.persist_sub(&self.subs[sub_index])?;
17779 self.finish_observation("syncular_sub_end", batch)
17780 })();
17781 if persisted.is_err() {
17782 self.rollback_observation("syncular_sub_end");
17783 self.subs[sub_index] = previous;
17784 }
17785 persisted
17786 }
17787
17788 fn apply_section_body(
17789 &mut self,
17790 transport: &mut dyn Transport,
17791 sub_index: usize,
17792 body: Vec<Frame>,
17793 fresh: bool,
17794 meta: &RequestMeta,
17795 report: &mut SyncReport,
17796 ) -> Result<(), SectionError> {
17797 let mut saw_segment = false;
17798 for frame in body {
17799 match frame {
17800 Frame::Commit {
17801 tables,
17802 changes,
17803 commit_seq,
17804 ..
17805 } => {
17806 self.apply_commit_frame(&tables, &changes, Some(commit_seq))
17807 .map_err(|(c, m)| SectionError::Abort(c, m))?;
17808 report.commits_applied += 1;
17809 }
17810 Frame::SegmentInline { payload } => {
17811 let segment = decode_rows_segment(&payload)
17812 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
17813 self.progress.update(|p| {
17814 p.phase = ProgressPhase::Import;
17815 p.subscription_id = Some(self.subs[sub_index].id.clone());
17816 p.table = Some(self.subs[sub_index].table.clone());
17817 p.segment_id = None;
17818 p.bytes_received = payload.len() as u64;
17819 p.bytes_total = Some(payload.len() as u64);
17820 p.rows_processed = 0;
17821 p.rows_total =
17822 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
17823 });
17824 let first = !saw_segment;
17825 saw_segment = true;
17826 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
17827 report.segment_rows_applied += applied;
17828 }
17829 Frame::SegmentRef {
17830 segment_id,
17831 byte_length,
17832 media_type,
17833 table,
17834 row_count,
17835 as_of_commit_seq,
17836 scope_digest,
17837 row_cursor,
17838 next_row_cursor,
17839 url,
17840 url_expires_at_ms,
17841 ..
17842 } => {
17843 let advertised = match media_type {
17846 MediaType::Rows => {
17847 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
17848 || meta.accept & ACCEPT_INLINE_ROWS != 0
17849 }
17850 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
17851 };
17852 if !advertised {
17853 return Err(SectionError::Abort(
17854 "sync.invalid_request".to_owned(),
17855 format!(
17856 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
17857 media_type.name()
17858 ),
17859 ));
17860 }
17861 self.progress.update(|p| {
17862 p.phase = ProgressPhase::Download;
17863 p.subscription_id = Some(self.subs[sub_index].id.clone());
17864 p.table = Some(table.clone());
17865 p.segment_id = Some(segment_id.clone());
17866 p.bytes_received = 0;
17867 p.bytes_total = u64::try_from(byte_length).ok();
17868 p.rows_processed = 0;
17869 p.rows_total = u64::try_from(row_count).ok();
17870 });
17871 let observer = self.progress.clone();
17872 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
17873 let bytes = if let Some(url) = url {
17874 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
17879 return Err(SectionError::Abort(
17880 "sync.invalid_request".to_owned(),
17881 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
17882 .to_owned(),
17883 ));
17884 }
17885 if url_expires_at_ms.is_some_and(|exp| {
17887 exp <= transport
17888 .segment_fetch_started_at(&url)
17889 .unwrap_or_else(|| self.clock_now_ms())
17890 }) {
17891 return Err(SectionError::Abort(
17892 "sync.segment_expired".to_owned(),
17893 format!(
17894 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
17895 ),
17896 ));
17897 }
17898 transport
17899 .fetch_url(&url, &mut on_progress)
17900 .map_err(|e| SectionError::Abort(e.code, e.message))?
17901 } else {
17902 let requested_scopes_json =
17903 canonical_scope_json(&self.subs[sub_index].requested);
17904 transport
17905 .download_segment(
17906 &SegmentRequest {
17907 segment_id: segment_id.clone(),
17908 table,
17909 requested_scopes_json,
17910 },
17911 &mut on_progress,
17912 )
17913 .map_err(|e| SectionError::Abort(e.code, e.message))?
17914 };
17915 on_progress(bytes.len() as u64);
17917 let digest = Sha256::digest(&bytes);
17918 let expected = segment_id
17919 .strip_prefix("sha256:")
17920 .unwrap_or(segment_id.as_str());
17921 if bytes_to_hex(&digest) != expected {
17922 self.schedule_background_retry();
17924 return Err(SectionError::Abort(
17925 "sync.invalid_request".to_owned(),
17926 "segment bytes do not match the content address (§5.1)".to_owned(),
17927 ));
17928 }
17929 self.progress.update(|p| p.phase = ProgressPhase::Import);
17930 if media_type == MediaType::Sqlite {
17931 if row_cursor.is_some() || next_row_cursor.is_some() {
17934 return Err(SectionError::Abort(
17935 "sync.invalid_request".to_owned(),
17936 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
17937 .to_owned(),
17938 ));
17939 }
17940 let first = !saw_segment;
17941 saw_segment = true;
17942 let applied = self.apply_sqlite_segment(
17943 sub_index,
17944 &bytes,
17945 fresh && first,
17946 row_count,
17947 as_of_commit_seq,
17948 &scope_digest,
17949 )?;
17950 report.segment_rows_applied += applied;
17951 } else {
17952 let segment = decode_rows_segment(&bytes).map_err(|e| {
17953 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
17954 })?;
17955 let first = row_cursor.is_none();
17956 saw_segment = true;
17957 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
17958 report.segment_rows_applied += applied;
17959 }
17960 }
17961 Frame::Unknown { .. } => {}
17962 Frame::Error { code, message, .. } => {
17963 return Err(SectionError::Abort(code, message))
17964 }
17965 _ => {
17966 return Err(SectionError::Abort(
17967 "sync.invalid_request".to_owned(),
17968 "unexpected frame inside a subscription section".to_owned(),
17969 ));
17970 }
17971 }
17972 }
17973 Ok(())
17974 }
17975
17976 fn apply_commit_frame(
17978 &mut self,
17979 tables: &[String],
17980 changes: &[ssp2::model::Change],
17981 commit_seq: Option<i64>,
17982 ) -> Result<(), (String, String)> {
17983 #[cfg(feature = "bench-internals")]
17984 let _phase = self.benchmark_phases.start(Phase::CommitApply);
17985 let was_dirty = self.overlay_dirty.snapshot();
17986 self.coalesce_unit(changes.len())
17987 .and_then(|()| self.begin_observation("syncular_commit"))
17988 .map_err(|message| ("storage.failed".into(), message))?;
17989 let applied = (|| {
17990 let mut batch = ChangeAccumulator::default();
17991 self.record_commit_changes(&mut batch, tables, changes);
17992 if let Some(sequence) = commit_seq {
17993 for change in changes {
17994 let table = tables.get(change.table_index as usize).ok_or_else(|| {
17995 (
17996 "sync.invalid_request".to_owned(),
17997 "change tableIndex out of range".to_owned(),
17998 )
17999 })?;
18000 if self
18001 .outbox
18002 .iter()
18003 .flat_map(|commit| &commit.ops)
18004 .any(|op| op.table == *table && op.row_id == change.row_id)
18005 {
18006 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])
18007 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
18008 }
18009 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])
18010 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
18011 }
18012 }
18013 self.apply_commit_changes(tables, changes)?;
18014 self.rebuild_overlay_if_dirty()
18015 .map_err(|message| ("storage.failed".into(), message))?;
18016 self.finish_observation("syncular_commit", batch)
18017 .map_err(|message| ("storage.failed".into(), message))
18018 })();
18019 if applied.is_err() {
18020 self.rollback_observation("syncular_commit");
18021 self.overlay_dirty.restore(was_dirty.clone());
18022 }
18023 applied
18024 }
18025
18026 fn apply_commit_changes(
18027 &mut self,
18028 tables: &[String],
18029 changes: &[ssp2::model::Change],
18030 ) -> Result<(), (String, String)> {
18031 let mirror_visible = !self.overlay_dirty.get()
18036 && self.failed_commits.is_empty()
18037 && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
18038 || changes.iter().all(|change| {
18039 tables
18040 .get(change.table_index as usize)
18041 .and_then(|name| self.schema.table(name))
18042 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
18043 }));
18044 for change in changes {
18045 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
18046 (
18047 "sync.invalid_request".to_owned(),
18048 "change tableIndex out of range".to_owned(),
18049 )
18050 })?;
18051 let table = self.schema.table(table_name).ok_or_else(|| {
18052 (
18053 "sync.schema_mismatch".to_owned(),
18054 format!("change targets unknown table {table_name:?}"),
18055 )
18056 })?;
18057 match change.op {
18058 Op::Upsert => {
18059 let payload = change.row.as_ref().ok_or_else(|| {
18060 (
18061 "sync.invalid_request".to_owned(),
18062 "upsert change without row payload".to_owned(),
18063 )
18064 })?;
18065 let row = {
18067 #[cfg(feature = "bench-internals")]
18068 let _phase = self.benchmark_phases.start(Phase::RowDecode);
18069 decode_row_bytes(table, payload, &self.encryption)
18070 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
18071 };
18072 let version = change.row_version.unwrap_or(0);
18073 let table_name = table.name.clone();
18074 self.write_base_row(&table_name, &row, version)
18075 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
18076 if mirror_visible {
18077 self.write_row(&visible_table(&table_name), &table_name, &row, version)
18078 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
18079 }
18080 }
18081 Op::Delete => {
18082 self.delete_base_row(table_name, &change.row_id)
18083 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
18084 if mirror_visible {
18085 let sql = format!(
18086 "DELETE FROM {} WHERE {}",
18087 visible_table(table_name),
18088 row_id_predicate(table)
18089 );
18090 self.conn
18091 .prepare_cached(&sql)
18092 .and_then(|mut statement| {
18093 statement.execute(rusqlite::params![change.row_id])
18094 })
18095 .map_err(|error| {
18096 ("sync.invalid_request".to_owned(), error.to_string())
18097 })?;
18098 }
18099 }
18100 }
18101 }
18102 if mirror_visible {
18103 {
18104 let changed_rows: HashSet<(&str, &str)> = changes
18105 .iter()
18106 .map(|change| {
18107 (
18108 tables[change.table_index as usize].as_str(),
18109 change.row_id.as_str(),
18110 )
18111 })
18112 .collect();
18113 let acknowledged = self
18114 .acknowledged_operations(Some(&changed_rows), None)
18115 .map_err(|error| ("storage.failed".to_owned(), error))?;
18116 self.apply_outbox_ops(&acknowledged)
18117 .map_err(|message| ("storage.failed".into(), message))?;
18118 self.apply_outbox_ops(
18119 self.outbox
18120 .iter()
18121 .flat_map(|commit| &commit.ops)
18122 .filter(|op| {
18123 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
18124 }),
18125 )
18126 .map_err(|message| ("storage.failed".into(), message))?;
18127 }
18128 self.overlay_dirty.set(false);
18129 }
18130 Ok(())
18131 }
18132
18133 fn apply_segment(
18138 &mut self,
18139 sub_index: usize,
18140 segment: &RowsSegment,
18141 first_fresh_page: bool,
18142 ) -> Result<u32, SectionError> {
18143 let (sub_table, effective) = {
18144 let sub = &self.subs[sub_index];
18145 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18146 };
18147 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
18148 SectionError::Abort(
18149 "sync.schema_mismatch".to_owned(),
18150 format!("subscription table {sub_table:?} is not in the client schema"),
18151 )
18152 })?;
18153 let matches = segment.table == table.name
18158 && segment.schema_version == self.schema.version
18159 && segment.columns.len() == table.wire_columns.len()
18160 && segment
18161 .columns
18162 .iter()
18163 .zip(table.wire_columns.iter())
18164 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
18165 if !matches {
18166 return Err(SectionError::Abort(
18167 "sync.schema_mismatch".to_owned(),
18168 "segment column table does not match the generated schema (§5.2)".to_owned(),
18169 ));
18170 }
18171 let mut applied = 0u32;
18172 let empty = Vec::new();
18173 let blocks = if segment.blocks.is_empty() {
18174 std::slice::from_ref(&empty)
18175 } else {
18176 &segment.blocks
18177 };
18178 for (index, block) in blocks.iter().enumerate() {
18179 let clear = first_fresh_page && index == 0;
18180 if !clear && block.is_empty() {
18181 continue;
18182 }
18183 let was_dirty = self.overlay_dirty.snapshot();
18184 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
18185 && self
18186 .outbox
18187 .iter()
18188 .flat_map(|commit| &commit.ops)
18189 .any(|op| op.table == table.name);
18190 let mirror_visible = !reconcile_pending
18191 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
18192 self.coalesce_unit(block.len())
18193 .and_then(|()| self.begin_observation("syncular_segment_block"))
18194 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18195 let outcome = (|| {
18196 self.rebuild_overlay_if_dirty()
18197 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18198 let mut changed_keys = Vec::new();
18199 let mut batch = ChangeAccumulator::default();
18200 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
18201 batch.table(&table.name);
18202 }
18203 if clear {
18204 self.purge_scope_rows(&table.name, &effective)
18205 .map_err(|()| SectionError::FailClosed)?;
18206 }
18207 for (row_index, row) in block.iter().enumerate() {
18208 let decrypted;
18209 let values = if table.has_encrypted_columns() {
18210 let mut values = row.values.clone();
18211 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
18212 .map_err(|message| {
18213 SectionError::Abort("client.decrypt_failed".into(), message)
18214 })?;
18215 decrypted = values;
18216 &decrypted
18217 } else {
18218 &row.values
18219 };
18220 self.write_base_row(&table.name, values, row.server_version)
18221 .map_err(|message| {
18222 SectionError::Abort("sync.invalid_request".into(), message)
18223 })?;
18224 if reconcile_pending {
18225 changed_keys.push(
18226 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
18227 |message| SectionError::Abort("storage.failed".into(), message),
18228 )?,
18229 );
18230 }
18231 if mirror_visible {
18232 self.write_row(
18233 &visible_table(&table.name),
18234 &table.name,
18235 values,
18236 row.server_version,
18237 )
18238 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18239 }
18240 let processed = u64::from(applied) + row_index as u64 + 1;
18241 if processed.is_multiple_of(1024) {
18242 self.progress.update(|p| p.rows_processed = processed);
18243 }
18244 }
18245 if reconcile_pending {
18246 self.reconcile_imported_rows(&table, &mut changed_keys)
18247 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18248 }
18249 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
18250 self.apply_outbox_ops(
18251 self.outbox
18252 .iter()
18253 .flat_map(|commit| &commit.ops)
18254 .filter(|op| op.table == table.name),
18255 )
18256 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18257 self.overlay_dirty.set(false);
18258 } else {
18259 self.rebuild_overlay_if_dirty()
18260 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18261 }
18262 self.finish_observation("syncular_segment_block", batch)
18263 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
18264 })();
18265 if let Err(error) = outcome {
18266 self.rollback_observation("syncular_segment_block");
18267 self.overlay_dirty.restore(was_dirty.clone());
18268 return Err(error);
18269 }
18270 applied += block.len() as u32;
18271 self.progress
18272 .update(|p| p.rows_processed = u64::from(applied));
18273 std::thread::yield_now();
18274 }
18275 self.progress
18276 .update(|p| p.rows_processed = u64::from(applied));
18277 Ok(applied)
18278 }
18279
18280 fn apply_sqlite_segment(
18288 &mut self,
18289 sub_index: usize,
18290 bytes: &[u8],
18291 first_fresh_page: bool,
18292 row_count: i64,
18293 as_of_commit_seq: i64,
18294 scope_digest: &str,
18295 ) -> Result<u32, SectionError> {
18296 let invalid = |detail: &str| {
18297 SectionError::Abort(
18298 "sync.invalid_request".to_owned(),
18299 format!("sqlite segment rejected: {detail} (§5.3)"),
18300 )
18301 };
18302 let (sub_table, effective) = {
18303 let sub = &self.subs[sub_index];
18304 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18305 };
18306 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
18307 SectionError::Abort(
18308 "sync.schema_mismatch".to_owned(),
18309 format!("subscription table {sub_table:?} is not in the client schema"),
18310 )
18311 })?;
18312
18313 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
18314 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
18315 let img = match rusqlite::Connection::open_with_flags(
18316 &path,
18317 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
18318 ) {
18319 Ok(conn) => conn,
18320 Err(_) => {
18321 let _ = std::fs::remove_file(&path);
18322 return Err(invalid("bytes do not open as a SQLite database"));
18323 }
18324 };
18325 let outcome = self.apply_sqlite_image(
18326 &img,
18327 &table,
18328 first_fresh_page,
18329 &effective,
18330 row_count,
18331 as_of_commit_seq,
18332 scope_digest,
18333 );
18334 drop(img);
18335 let _ = std::fs::remove_file(&path);
18336 outcome
18337 }
18338
18339 #[allow(clippy::too_many_arguments)]
18340 fn apply_sqlite_image(
18341 &mut self,
18342 img: &rusqlite::Connection,
18343 table: &crate::schema::TableSchema,
18344 first_fresh_page: bool,
18345 effective: &[(String, Vec<String>)],
18346 row_count: i64,
18347 as_of_commit_seq: i64,
18348 scope_digest: &str,
18349 ) -> Result<u32, SectionError> {
18350 let invalid = |detail: String| {
18351 SectionError::Abort(
18352 "sync.invalid_request".to_owned(),
18353 format!("sqlite segment rejected: {detail} (§5.3)"),
18354 )
18355 };
18356
18357 type MetaRow = (i64, String, i64, i64, String, i64, i64);
18360 let meta: MetaRow = img
18361 .query_row(
18362 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
18363 \"scopeDigest\", \"rowCount\",
18364 (SELECT count(*) FROM _syncular_segment)
18365 FROM _syncular_segment",
18366 [],
18367 |row| {
18368 Ok((
18369 row.get(0)?,
18370 row.get(1)?,
18371 row.get(2)?,
18372 row.get(3)?,
18373 row.get(4)?,
18374 row.get(5)?,
18375 row.get(6)?,
18376 ))
18377 },
18378 )
18379 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
18380 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
18381 if meta_count != 1 {
18382 return Err(invalid(format!(
18383 "_syncular_segment must contain exactly one row, found {meta_count}"
18384 )));
18385 }
18386 if format != 1 {
18387 return Err(invalid(format!("format {format}")));
18388 }
18389 if meta_table != table.name {
18390 return Err(invalid(format!("image table {meta_table:?}")));
18391 }
18392 if schema_version != i64::from(self.schema.version) {
18393 return Err(invalid(format!("schemaVersion {schema_version}")));
18394 }
18395 if pin != as_of_commit_seq {
18396 return Err(invalid(format!("asOfCommitSeq {pin}")));
18397 }
18398 if digest != scope_digest {
18399 return Err(invalid("scopeDigest mismatch".to_owned()));
18400 }
18401 if meta_rows != row_count {
18402 return Err(invalid(format!("rowCount {meta_rows}")));
18403 }
18404
18405 let mut names: Vec<String> = Vec::new();
18407 let mut primary_keys: Vec<String> = Vec::new();
18408 {
18409 let mut stmt = img
18410 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
18411 .map_err(|_| invalid("image data table missing".to_owned()))?;
18412 let mut rows = stmt
18413 .query([])
18414 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
18415 while let Some(row) = rows
18416 .next()
18417 .map_err(|_| invalid("image data table unreadable".to_owned()))?
18418 {
18419 if row
18420 .get::<_, i64>(5)
18421 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
18422 > 0
18423 {
18424 primary_keys.push(row.get(1).map_err(|error| {
18425 invalid(Self::sqlite_failure(&self.storage_failure, error))
18426 })?);
18427 }
18428 names.push(
18429 row.get::<_, String>(1)
18430 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
18431 );
18432 }
18433 }
18434 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
18435 expected.push("_syncular_version");
18436 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
18437 return Err(SectionError::Abort(
18438 "sync.schema_mismatch".to_owned(),
18439 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
18440 ));
18441 }
18442
18443 if primary_keys != vec![table.primary_key.clone()] {
18444 return Err(invalid(
18445 "image primary key does not match generated schema".into(),
18446 ));
18447 }
18448 let actual_count: i64 = img
18449 .query_row(
18450 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
18451 [],
18452 |row| row.get(0),
18453 )
18454 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18455 if actual_count != row_count {
18456 return Err(invalid("image row count does not match descriptor".into()));
18457 }
18458 let insert = self.insert_row_sql(&base_table(&table.name), table);
18459 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
18460 let column_list = names
18461 .iter()
18462 .map(|n| quote_ident(n))
18463 .collect::<Vec<_>>()
18464 .join(", ");
18465 let mut stmt = img
18466 .prepare(&format!(
18467 "SELECT {column_list} FROM {} ORDER BY {}",
18468 quote_ident(&table.name),
18469 quote_ident(&table.primary_key)
18470 ))
18471 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18472 let mut rows = stmt
18473 .query([])
18474 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18475 let mut applied = 0u32;
18476 loop {
18477 let clear = applied == 0 && first_fresh_page;
18478 let was_dirty = self.overlay_dirty.snapshot();
18479 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
18480 && self
18481 .outbox
18482 .iter()
18483 .flat_map(|commit| &commit.ops)
18484 .any(|op| op.table == table.name);
18485 let mirror_visible = !reconcile_pending
18486 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
18487 self.coalesce_unit(1024)
18489 .and_then(|()| self.begin_observation("syncular_image_chunk"))
18490 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18491 let result = (|| {
18492 self.rebuild_overlay_if_dirty()
18493 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18494 let mut changed_keys = Vec::new();
18495 let mut batch = ChangeAccumulator::default();
18496 if clear {
18497 if self.scoped_rows_exist(&table.name, effective) {
18498 batch.table(&table.name);
18499 }
18500 self.purge_scope_rows(&table.name, effective)
18501 .map_err(|()| SectionError::FailClosed)?;
18502 }
18503 let mut processed = 0u32;
18504 {
18505 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
18506 invalid(Self::sqlite_failure(&self.storage_failure, error))
18507 })?;
18508 let mut mirror = if mirror_visible {
18509 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
18510 invalid(Self::sqlite_failure(&self.storage_failure, error))
18511 })?)
18512 } else {
18513 None
18514 };
18515 for _ in 0..1024 {
18516 let Some(row) = rows.next().map_err(|error| {
18517 invalid(Self::sqlite_failure(&self.storage_failure, error))
18518 })?
18519 else {
18520 break;
18521 };
18522 for (i, column) in table.columns.iter().enumerate() {
18523 let cell = row.get_ref(i).map_err(|error| {
18524 invalid(Self::sqlite_failure(&self.storage_failure, error))
18525 })?;
18526 let param = image_cell_param(column, cell).map_err(invalid)?;
18527 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
18528 invalid(Self::sqlite_failure(&self.storage_failure, error))
18529 })?;
18530 if let Some(mirror) = &mut mirror {
18531 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
18532 invalid(Self::sqlite_failure(&self.storage_failure, error))
18533 })?;
18534 }
18535 }
18536 let version_index = table.columns.len();
18537 let version: i64 = row.get(version_index).map_err(|error| {
18538 invalid(Self::sqlite_failure(&self.storage_failure, error))
18539 })?;
18540 if version < 1 {
18541 return Err(invalid("image row version must be positive".into()));
18542 }
18543 ins.raw_bind_parameter(version_index + 1, version)
18544 .map_err(|error| {
18545 invalid(Self::sqlite_failure(&self.storage_failure, error))
18546 })?;
18547 ins.raw_execute().map_err(|error| {
18548 invalid(Self::sqlite_failure(&self.storage_failure, error))
18549 })?;
18550 if let Some(mirror) = &mut mirror {
18551 mirror
18552 .raw_bind_parameter(version_index + 1, version)
18553 .map_err(|error| {
18554 invalid(Self::sqlite_failure(&self.storage_failure, error))
18555 })?;
18556 mirror.raw_execute().map_err(|error| {
18557 invalid(Self::sqlite_failure(&self.storage_failure, error))
18558 })?;
18559 }
18560 if reconcile_pending {
18561 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
18562 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
18563 )?);
18564 }
18565 processed += 1;
18566 }
18567 }
18568 if processed > 0 {
18569 batch.table(&table.name);
18570 self.overlay_dirty.table(&table.name);
18571 }
18572 if reconcile_pending {
18573 self.reconcile_imported_rows(table, &mut changed_keys)
18574 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18575 }
18576 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
18577 self.apply_outbox_ops(
18578 self.outbox
18579 .iter()
18580 .flat_map(|commit| &commit.ops)
18581 .filter(|op| op.table == table.name),
18582 )
18583 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18584 self.overlay_dirty.set(false);
18585 } else {
18586 self.rebuild_overlay_if_dirty()
18587 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18588 }
18589 self.finish_observation("syncular_image_chunk", batch)
18590 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18591 Ok(processed)
18592 })();
18593 let processed = match result {
18594 Ok(processed) => processed,
18595 Err(error) => {
18596 self.rollback_observation("syncular_image_chunk");
18597 self.overlay_dirty.restore(was_dirty.clone());
18598 return Err(error);
18599 }
18600 };
18601 applied += processed;
18602 self.progress
18603 .update(|p| p.rows_processed = u64::from(applied));
18604 std::thread::yield_now();
18605 if i64::from(applied) == actual_count {
18606 break;
18607 }
18608 }
18609 Ok(applied)
18610 }
18611
18612 fn reconcile_imported_rows(
18616 &self,
18617 table: &TableSchema,
18618 keys: &mut Vec<SqlValue>,
18619 ) -> Result<(), String> {
18620 let visible = visible_table(&table.name);
18621 let base = base_table(&table.name);
18622 let pk = quote_ident(&table.primary_key);
18623 let predicate = row_id_predicate(table);
18624 let mut lookup = self.conn.prepare_cached(&format!(
18625 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
18626 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18627 let mut deleted = HashSet::new();
18628 for op in self
18629 .outbox
18630 .iter()
18631 .flat_map(|commit| &commit.ops)
18632 .filter(|op| op.table == table.name)
18633 {
18634 if op.upsert {
18635 if let Some(values) = &op.values {
18636 let key = json_to_column_value(
18637 &table.columns[table.pk_index],
18638 values.get(&table.primary_key),
18639 )?;
18640 keys.push(owned_sql_value(RowParam::Cell(&key))?);
18641 }
18642 } else if deleted.insert(&op.row_id) {
18643 keys.extend(
18644 lookup
18645 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
18646 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
18647 .collect::<Result<Vec<_>, _>>()
18648 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
18649 );
18650 }
18651 }
18652 for chunk in keys.chunks(400) {
18655 let holes = vec!["?"; chunk.len()].join(",");
18656 self.conn
18657 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
18658 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
18659 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18660 }
18661 let updates = table
18662 .columns
18663 .iter()
18664 .map(|column| column.name.as_str())
18665 .chain(std::iter::once("_syncular_version"))
18666 .filter(|name| *name != table.primary_key)
18667 .map(|name| {
18668 let name = quote_ident(name);
18669 format!("{name}=excluded.{name}")
18670 })
18671 .collect::<Vec<_>>()
18672 .join(",");
18673 for chunk in keys.chunks(400) {
18674 let holes = vec!["?"; chunk.len()].join(",");
18675 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
18676 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
18677 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18678 }
18679 Ok(())
18680 }
18681
18682 fn compile_local_data_purge(
18685 &self,
18686 input: &LocalDataPurgeInput,
18687 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
18688 let invalid = |message: String| format!("sync.invalid_request: {message}");
18689 if input.purge_id.is_empty()
18690 || input.purge_id.len() > 128
18691 || !is_local_operation_code_like(&input.purge_id)
18692 {
18693 return Err(invalid(
18694 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
18695 ));
18696 }
18697 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
18698 return Err(invalid(format!(
18699 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
18700 )));
18701 }
18702
18703 let mut deduplicated: BTreeMap<
18704 String,
18705 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
18706 > = BTreeMap::new();
18707 for target in &input.targets {
18708 let table = self.schema.table(&target.table).ok_or_else(|| {
18709 invalid(format!(
18710 "local purge names unknown table {:?}",
18711 target.table
18712 ))
18713 })?;
18714 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
18715 return Err(invalid(format!(
18716 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
18717 target.table
18718 )));
18719 }
18720 let mut selectors = Vec::with_capacity(target.selectors.len());
18721 let mut canonical_selectors = BTreeMap::new();
18722 for (column_name, raw_values) in &target.selectors {
18723 let Some((column_index, column)) = table
18724 .columns
18725 .iter()
18726 .enumerate()
18727 .find(|(_, column)| column.name == *column_name)
18728 else {
18729 return Err(invalid(format!(
18730 "local purge target {:?} names unknown column {:?}",
18731 target.table, column_name
18732 )));
18733 };
18734 let encrypted = table
18735 .encrypted_columns
18736 .iter()
18737 .any(|candidate| candidate.index == column_index);
18738 if column.ty != ColumnType::String || encrypted {
18739 return Err(invalid(format!(
18740 "local purge selector {:?}.{:?} must be a plaintext string column",
18741 target.table, column_name
18742 )));
18743 }
18744 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
18745 return Err(invalid(format!(
18746 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
18747 target.table, column_name
18748 )));
18749 }
18750 let mut values = raw_values.clone();
18751 values.sort();
18752 values.dedup();
18753 if values.iter().any(|value| {
18754 value.is_empty()
18755 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
18756 || !is_local_operation_code_like(value)
18757 }) {
18758 return Err(invalid(format!(
18759 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
18760 )));
18761 }
18762 selectors.push((column_name.clone(), values.clone()));
18763 canonical_selectors.insert(column_name.clone(), values);
18764 }
18765 let canonical = LocalDataPurgeTarget {
18766 table: target.table.clone(),
18767 selectors: canonical_selectors,
18768 };
18769 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
18770 deduplicated.insert(
18771 key,
18772 (
18773 CompiledLocalDataPurgeTarget {
18774 table: target.table.clone(),
18775 selectors,
18776 },
18777 canonical,
18778 ),
18779 );
18780 }
18781 let targets = deduplicated
18782 .values()
18783 .map(|(compiled, _)| compiled.clone())
18784 .collect::<Vec<_>>();
18785 let canonical = deduplicated
18786 .values()
18787 .map(|(_, target)| target.clone())
18788 .collect::<Vec<_>>();
18789 let canonical_plan =
18790 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
18791 Ok((targets, canonical_plan))
18792 }
18793
18794 fn local_purge_base_row_ids(
18795 &self,
18796 targets: &[CompiledLocalDataPurgeTarget],
18797 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
18798 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
18799 for target in targets {
18800 let table = self
18801 .schema
18802 .table(&target.table)
18803 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
18804 let mut clauses = Vec::with_capacity(target.selectors.len());
18805 let mut params = Vec::new();
18806 for (column, values) in &target.selectors {
18807 clauses.push(format!(
18808 "{} IN ({})",
18809 quote_ident(column),
18810 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
18811 ));
18812 params.extend(values.iter().cloned().map(SqlValue::Text));
18813 }
18814 let sql = format!(
18815 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
18816 quote_ident(&table.primary_key),
18817 base_table(&target.table),
18818 clauses.join(" AND ")
18819 );
18820 let mut statement = self
18821 .conn
18822 .prepare(&sql)
18823 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18824 let rows = statement
18825 .query_map(rusqlite::params_from_iter(params), |row| {
18826 let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
18827 render_row_id_json(Some(&value))
18828 .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
18829 })
18830 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18831 let ids = by_table.entry(target.table.clone()).or_default();
18832 for row in rows {
18833 ids.insert(
18834 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
18835 );
18836 }
18837 }
18838 Ok(by_table)
18839 }
18840
18841 fn local_purge_values_match(
18842 target: &CompiledLocalDataPurgeTarget,
18843 values: &Map<String, Value>,
18844 ) -> bool {
18845 target.selectors.iter().all(|(column, allowed)| {
18846 values
18847 .get(column)
18848 .and_then(Value::as_str)
18849 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
18850 })
18851 }
18852
18853 pub fn purge_local_data(
18857 &mut self,
18858 input: &LocalDataPurgeInput,
18859 ) -> Result<LocalDataPurgeResult, String> {
18860 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
18861 let meta_key = format!("localPurge:{}", input.purge_id);
18862 let applied_plan = self
18863 .conn
18864 .query_row(
18865 "SELECT value FROM _syncular_meta WHERE key = ?1",
18866 rusqlite::params![meta_key],
18867 |row| row.get::<_, String>(0),
18868 )
18869 .optional()
18870 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18871 if let Some(applied_plan) = applied_plan {
18872 if applied_plan != canonical_plan {
18873 return Err(format!(
18874 "sync.invalid_request: local purge id {:?} was already used with a different plan",
18875 input.purge_id
18876 ));
18877 }
18878 return Ok(LocalDataPurgeResult {
18879 already_applied: true,
18880 purged_rows: 0,
18881 dropped_commits: 0,
18882 });
18883 }
18884
18885 let _ = self.drop_previous_version();
18889
18890 let prior_outbox = self.outbox.clone();
18891 let prior_failed = self.failed_commits.clone();
18892 let prior_rejections = self.rejections.clone();
18893 let prior_conflicts = self.conflicts.clone();
18894 let prior_overlay_dirty = self.overlay_dirty.snapshot();
18895 self.begin_observation("syncular_local_purge")?;
18896 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
18897 let row_ids = self.local_purge_base_row_ids(&targets)?;
18898 let retained_dropped = self.drop_failed_commits(|table, values| {
18899 targets.iter().any(|target| {
18900 target.table == table && Self::local_purge_values_match(target, values)
18901 })
18902 })?;
18903 let doomed = self
18904 .outbox
18905 .iter()
18906 .filter(|commit| {
18907 commit.ops.iter().any(|operation| {
18908 let matching_targets = targets
18909 .iter()
18910 .filter(|target| target.table == operation.table)
18911 .collect::<Vec<_>>();
18912 if matching_targets.is_empty() {
18913 return false;
18914 }
18915 if row_ids
18916 .get(&operation.table)
18917 .is_some_and(|ids| ids.contains(&operation.row_id))
18918 {
18919 return true;
18920 }
18921 operation.values.as_ref().is_some_and(|values| {
18922 matching_targets
18923 .iter()
18924 .any(|target| Self::local_purge_values_match(target, values))
18925 })
18926 })
18927 })
18928 .cloned()
18929 .collect::<Vec<_>>();
18930 let doomed_ids = doomed
18931 .iter()
18932 .map(|commit| commit.client_commit_id.clone())
18933 .collect::<BTreeSet<_>>();
18934 let mut batch = ChangeAccumulator::default();
18935 for commit in &retained_dropped {
18936 batch.conflicts = true;
18937 batch.rejections = true;
18938 batch.outcomes = true;
18939 for operation in &commit.ops {
18940 batch.table(&operation.table);
18941 }
18942 }
18943 let mut rejections = Vec::new();
18944 for commit in &doomed {
18945 for operation in &commit.ops {
18946 batch.table(&operation.table);
18947 }
18948 let results = commit
18949 .ops
18950 .iter()
18951 .enumerate()
18952 .map(|(op_index, operation)| {
18953 let rejection = RejectionRecord {
18954 client_commit_id: commit.client_commit_id.clone(),
18955 op_index: op_index as i32,
18956 code: "client.local_data_purged".to_owned(),
18957 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
18958 retryable: false,
18959 details: None,
18960 operation: Some(CommitOperation::from(operation)),
18961 };
18962 rejections.push(rejection.clone());
18963 CommitOperationOutcome::Error { rejection }
18964 })
18965 .collect::<Vec<_>>();
18966 self.persist_commit_outcome(
18967 &commit.client_commit_id,
18968 CommitOutcomeStatus::Rejected,
18969 &results,
18970 Some(&commit.ops),
18971 )?;
18972 self.delete_outbox_persisted(&commit.client_commit_id)?;
18973 }
18974 if !doomed.is_empty() {
18975 self.outbox
18976 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
18977 self.rejections.extend(rejections);
18978 self.prune_commit_outcomes()?;
18979 batch.status = true;
18980 batch.rejections = true;
18981 batch.outcomes = true;
18982 }
18983
18984 let mut purged_rows = 0usize;
18985 for (table, ids) in &row_ids {
18986 if ids.is_empty() {
18987 continue;
18988 }
18989 batch.table(table);
18990 for row_id in ids {
18991 self.delete_base_row(table, row_id)?;
18992 purged_rows += 1;
18993 }
18994 }
18995 self.rebuild_overlay_if_dirty()?;
18996 self.delete_unreferenced_cached_blobs()?;
18997 self.conn
18998 .execute(
18999 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
19000 rusqlite::params![meta_key, canonical_plan],
19001 )
19002 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19003 Ok((
19004 batch,
19005 LocalDataPurgeResult {
19006 already_applied: false,
19007 purged_rows,
19008 dropped_commits: doomed.len(),
19009 },
19010 ))
19011 })();
19012 let (batch, result) = match applied {
19013 Ok(value) => value,
19014 Err(error) => {
19015 self.rollback_observation("syncular_local_purge");
19016 self.outbox = prior_outbox;
19017 self.failed_commits = prior_failed;
19018 self.rejections = prior_rejections;
19019 self.conflicts = prior_conflicts;
19020 self.overlay_dirty.restore(prior_overlay_dirty.clone());
19021 return Err(error);
19022 }
19023 };
19024 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
19025 self.rollback_observation("syncular_local_purge");
19026 self.outbox = prior_outbox;
19027 self.failed_commits = prior_failed;
19028 self.rejections = prior_rejections;
19029 self.conflicts = prior_conflicts;
19030 self.overlay_dirty.restore(prior_overlay_dirty.clone());
19031 return Err(error);
19032 }
19033 self.cancel_sync_round();
19034 Ok(result)
19035 }
19036
19037 pub fn rebootstrap_local_data(
19043 &mut self,
19044 input: &LocalDataRebootstrapInput,
19045 ) -> Result<LocalDataRebootstrapResult, String> {
19046 if input.rebootstrap_id.is_empty()
19047 || input.rebootstrap_id.len() > 128
19048 || !is_local_operation_code_like(&input.rebootstrap_id)
19049 {
19050 return Err(
19051 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
19052 .to_owned(),
19053 );
19054 }
19055 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
19056 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
19057 let (retained_commits, reset_subscriptions) =
19058 decode_local_rebootstrap_receipt(&persisted)?;
19059 return Ok(LocalDataRebootstrapResult {
19060 already_applied: true,
19061 retained_commits,
19062 reset_subscriptions,
19063 });
19064 }
19065 if self.stopped || self.schema_floor.is_some() {
19066 return Err(
19067 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
19068 .to_owned(),
19069 );
19070 }
19071
19072 let retained_commits = self.outbox.len();
19073 let reset_subscriptions = self.subs.len();
19074 let prior_subs = self.subs.clone();
19075 let prior_upgrading = self.upgrading;
19076 let prior_stopped = self.stopped;
19077 let prior_schema_floor = self.schema_floor.clone();
19078 let prior_overlay_dirty = self.overlay_dirty.snapshot();
19079 let prior_sync_needed = self.sync_needed;
19080 let prior_sync_intents = self.sync_intent_queue.clone();
19081 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
19082
19083 let prior_active_round = self.active_round;
19084 self.begin_observation("syncular_local_rebootstrap")?;
19085 let mut batch = ChangeAccumulator::default();
19086 let applied = (|| -> Result<(), String> {
19087 self.run_schema_reset_observed(&mut batch, false)?;
19088 self.conn
19089 .execute(
19090 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
19091 rusqlite::params![meta_key, receipt],
19092 )
19093 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19094 self.sync_needed = true;
19095 self.sync_intent_queue.push_back(SyncIntent::Interactive);
19096 batch.status = true;
19097 Ok(())
19098 })();
19099 if let Err(error) = applied {
19100 self.rollback_observation("syncular_local_rebootstrap");
19101 self.active_round = prior_active_round;
19102 self.subs = prior_subs;
19103 self.upgrading = prior_upgrading;
19104 self.stopped = prior_stopped;
19105 self.schema_floor = prior_schema_floor;
19106 self.overlay_dirty.restore(prior_overlay_dirty.clone());
19107 self.sync_needed = prior_sync_needed;
19108 self.sync_intent_queue = prior_sync_intents;
19109 return Err(error);
19110 }
19111 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
19112 self.rollback_observation("syncular_local_rebootstrap");
19113 self.active_round = prior_active_round;
19114 self.subs = prior_subs;
19115 self.upgrading = prior_upgrading;
19116 self.stopped = prior_stopped;
19117 self.schema_floor = prior_schema_floor;
19118 self.overlay_dirty.restore(prior_overlay_dirty.clone());
19119 self.sync_needed = prior_sync_needed;
19120 self.sync_intent_queue = prior_sync_intents;
19121 return Err(error);
19122 }
19123
19124 Ok(LocalDataRebootstrapResult {
19125 already_applied: false,
19126 retained_commits,
19127 reset_subscriptions,
19128 })
19129 }
19130
19131 fn purge_scope_rows(
19136 &mut self,
19137 table_name: &str,
19138 effective: &[(String, Vec<String>)],
19139 ) -> Result<(), ()> {
19140 if effective.is_empty() {
19141 return Ok(());
19142 }
19143 let table = self.schema.table(table_name).ok_or(())?.clone();
19144 let mut clauses = Vec::new();
19145 let mut params: Vec<SqlValue> = Vec::new();
19146 for (variable, values) in effective {
19147 let column = table.scope_column(variable).ok_or(())?;
19148 let placeholders: Vec<String> = values
19149 .iter()
19150 .map(|v| {
19151 params.push(SqlValue::Text(v.clone()));
19152 "?".to_owned()
19153 })
19154 .collect();
19155 clauses.push(format!(
19156 "{} IN ({})",
19157 quote_ident(column),
19158 placeholders.join(", ")
19159 ));
19160 }
19161 let sql = format!(
19162 "DELETE FROM {} WHERE {}",
19163 base_table(table_name),
19164 clauses.join(" AND ")
19165 );
19166 self.overlay_dirty.table(table_name);
19167 self.conn
19168 .execute(&sql, rusqlite::params_from_iter(¶ms))
19169 .map_err(|_| ())?;
19170 self.conn
19171 .execute(
19172 &format!(
19173 "DELETE FROM {} WHERE {}",
19174 visible_table(table_name),
19175 clauses.join(" AND ")
19176 ),
19177 rusqlite::params_from_iter(¶ms),
19178 )
19179 .map_err(|_| ())?;
19180 Ok(())
19181 }
19182
19183 fn drop_doomed_outbox(
19186 &mut self,
19187 table_name: &str,
19188 effective: &[(String, Vec<String>)],
19189 ) -> Result<bool, String> {
19190 if effective.is_empty() {
19191 return Ok(false);
19192 }
19193 let Some(table) = self.schema.table(table_name).cloned() else {
19194 return Ok(false);
19195 };
19196 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
19197 for (variable, values) in effective {
19198 match table.scope_column(variable) {
19199 Some(column) => mappings.push((column, values)),
19200 None => return Ok(false), }
19202 }
19203 let doomed: Vec<OutboxCommit> = self
19204 .outbox
19205 .iter()
19206 .filter(|commit| {
19207 commit.ops.iter().any(|op| {
19208 op.upsert
19209 && op.table == table_name
19210 && op.values.as_ref().is_some_and(|values| {
19211 mappings
19212 .iter()
19213 .all(|(column, allowed)| match values.get(*column) {
19214 Some(Value::String(s)) => allowed.contains(s),
19215 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
19216 Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
19217 _ => false,
19218 })
19219 })
19220 })
19221 })
19222 .cloned()
19223 .collect();
19224 if doomed.is_empty() {
19225 return Ok(false);
19226 }
19227 let mut rejections = Vec::new();
19228 for commit in &doomed {
19229 let results = commit
19230 .ops
19231 .iter()
19232 .enumerate()
19233 .map(|(op_index, operation)| {
19234 let rejection = RejectionRecord {
19235 client_commit_id: commit.client_commit_id.clone(),
19236 op_index: op_index as i32,
19237 code: "sync.scope_revoked".to_owned(),
19238 message: "the commit was dropped because its effective scope was revoked"
19239 .to_owned(),
19240 retryable: false,
19241 details: None,
19242 operation: Some(CommitOperation::from(operation)),
19243 };
19244 rejections.push(rejection.clone());
19245 CommitOperationOutcome::Error { rejection }
19246 })
19247 .collect::<Vec<_>>();
19248 self.persist_commit_outcome(
19249 &commit.client_commit_id,
19250 CommitOutcomeStatus::Rejected,
19251 &results,
19252 Some(&commit.ops),
19253 )?;
19254 self.delete_outbox_persisted(&commit.client_commit_id)?;
19255 }
19256 self.prune_commit_outcomes()?;
19257 let doomed_ids = doomed
19258 .iter()
19259 .map(|commit| commit.client_commit_id.as_str())
19260 .collect::<BTreeSet<_>>();
19261 self.outbox
19262 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
19263 self.rejections.extend(rejections);
19264 Ok(true)
19265 }
19266
19267 pub fn upload_blob(
19274 &mut self,
19275 bytes: &[u8],
19276 media_type: Option<String>,
19277 name: Option<String>,
19278 ) -> Result<Value, String> {
19279 let blob_id = blob_id_for(bytes);
19280 let now = self.clock_now_ms();
19281 let tx = self
19282 .conn
19283 .transaction()
19284 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19285 tx.execute(
19286 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
19287 ON CONFLICT(blob_id) DO NOTHING",
19288 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
19289 )
19290 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19291 tx.execute(
19292 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
19293 rusqlite::params![blob_id, media_type, now],
19294 )
19295 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19296 tx.commit()
19297 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19298 self.enforce_blob_cache_cap();
19299 let mut obj = Map::new();
19300 obj.insert("blobId".to_owned(), Value::from(blob_id));
19301 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
19302 if let Some(mt) = media_type {
19303 obj.insert("mediaType".to_owned(), Value::from(mt));
19304 }
19305 if let Some(n) = name {
19306 obj.insert("name".to_owned(), Value::from(n));
19307 }
19308 Ok(Value::Object(obj))
19309 }
19310
19311 pub fn fetch_blob_bytes(
19315 &mut self,
19316 transport: &mut dyn Transport,
19317 blob_id_or_ref: &str,
19318 ) -> Result<FetchedBlob, (String, String)> {
19319 let simple = |m: String| ("client.failed".to_owned(), m);
19320 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
19321 blob_id_or_ref.to_owned()
19322 } else {
19323 let value: Value = serde_json::from_str(blob_id_or_ref)
19324 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
19325 value
19326 .get("blobId")
19327 .and_then(Value::as_str)
19328 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
19329 .to_owned()
19330 };
19331 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
19332 return Ok(cached);
19333 }
19334 if !self.transport_enabled {
19335 return Err(("sync.offline".into(), "transport gate is closed".into()));
19336 }
19337 #[cfg(feature = "bench-internals")]
19343 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
19344 let bytes = match transport
19345 .blob_download(&blob_id)
19346 .map_err(|e| (e.code, e.message))?
19347 {
19348 BlobDownload::Bytes(bytes) => bytes,
19349 BlobDownload::Url {
19350 url,
19351 url_expires_at_ms,
19352 } => {
19353 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
19355 return Err((
19356 "sync.segment_expired".to_owned(),
19357 format!(
19358 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
19359 ),
19360 ));
19361 }
19362 transport
19363 .fetch_blob_url(&url)
19364 .map_err(|e| (e.code, e.message))?
19365 }
19366 };
19367 #[cfg(feature = "bench-internals")]
19368 drop(download_phase);
19369 #[cfg(feature = "bench-internals")]
19370 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
19371 if blob_id_for(&bytes) != blob_id {
19373 return Err(simple(format!(
19374 "blob content address mismatch for {blob_id}"
19375 )));
19376 }
19377 #[cfg(feature = "bench-internals")]
19378 drop(validate_phase);
19379 #[cfg(feature = "bench-internals")]
19380 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
19381 let now = self.clock_now_ms();
19382 self.conn
19383 .execute(
19384 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
19385 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
19386 )
19387 .map_err(|e| simple(e.to_string()))?;
19388 self.conn
19389 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
19390 .map_err(|e| simple(e.to_string()))?;
19391 self.enforce_blob_cache_cap();
19392 #[cfg(feature = "bench-internals")]
19393 drop(insert_phase);
19394 self.get_cached_blob_bytes(&blob_id)
19395 .map_err(simple)?
19396 .ok_or_else(|| simple("blob cache write failed".to_owned()))
19397 }
19398
19399 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
19400 #[cfg(feature = "bench-internals")]
19401 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
19402 let mut stmt = self
19403 .conn
19404 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
19405 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19406 let mut rows = stmt
19407 .query(rusqlite::params![blob_id])
19408 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19409 if let Some(row) = rows
19410 .next()
19411 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19412 {
19413 let bytes: Vec<u8> = row
19414 .get(0)
19415 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19416 let byte_length: i64 = row
19417 .get(1)
19418 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19419 let media_type: Option<String> = row
19420 .get(2)
19421 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19422 return Ok(Some(FetchedBlob {
19423 blob_id: blob_id.to_owned(),
19424 byte_length,
19425 bytes,
19426 media_type,
19427 }));
19428 }
19429 Ok(None)
19430 }
19431
19432 fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
19434 let pending: Vec<(String, Option<String>)> = {
19435 let mut stmt = self
19436 .conn
19437 .prepare(
19438 "SELECT blob_id, media_type FROM (
19439 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
19440 UNION ALL
19441 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
19442 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
19443 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
19444 ) ORDER BY created_at_ms, blob_id",
19445 )
19446 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19447 let rows = stmt
19448 .query_map([], |row| {
19449 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
19450 })
19451 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19452 rows.collect::<Result<_, _>>()
19453 .map_err(|error| match error {
19454 rusqlite::Error::InvalidColumnType(..)
19455 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
19456 "sync.local_corrupt",
19457 "Pending blob upload metadata is invalid",
19458 ),
19459 _ => TransportError::new("client.failed", error.to_string()),
19460 })?
19461 };
19462 let mut uploads = Vec::new();
19463 for (blob_id, media_type) in pending {
19464 let bytes = (|| {
19465 let stored: Option<(SqlValue, SqlValue)> = self
19466 .conn
19467 .query_row(
19468 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
19469 rusqlite::params![blob_id],
19470 |row| Ok((row.get(0)?, row.get(1)?)),
19471 )
19472 .optional()
19473 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19474 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
19475 return Err(TransportError::new(
19476 "sync.local_corrupt",
19477 "Pending blob upload body is missing or corrupt",
19478 ));
19479 };
19480 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
19481 return Err(TransportError::new(
19482 "sync.local_corrupt",
19483 "Pending blob upload body is missing or corrupt",
19484 ));
19485 }
19486 Ok(bytes)
19487 })();
19488 let failed = bytes.is_err();
19489 uploads.push(crate::round::PendingUpload {
19490 id: blob_id,
19491 bytes,
19492 media_type,
19493 });
19494 if failed {
19495 break;
19496 }
19497 }
19498 Ok(uploads)
19499 }
19500
19501 #[cfg(test)]
19502 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
19503 for upload in self.pending_blob_uploads()? {
19504 self.upload_one(
19505 transport,
19506 &upload.id,
19507 &upload.bytes?,
19508 upload.media_type.as_deref(),
19509 )?;
19510 self.conn
19511 .execute(
19512 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
19513 rusqlite::params![upload.id],
19514 )
19515 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19516 }
19517 Ok(())
19518 }
19519
19520 #[cfg(test)]
19527 fn upload_one(
19528 &self,
19529 transport: &mut dyn Transport,
19530 blob_id: &str,
19531 bytes: &[u8],
19532 media_type: Option<&str>,
19533 ) -> Result<(), TransportError> {
19534 crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
19535 }
19536
19537 fn visible_blob_ids_sql(&self) -> String {
19538 let mut selects = Vec::new();
19539 for table in &self.schema.tables {
19540 for column in table
19541 .columns
19542 .iter()
19543 .filter(|column| column.ty == ColumnType::BlobRef)
19544 {
19545 let identifier = quote_ident(&column.name);
19546 selects.push(format!(
19547 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
19548 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
19549 AND json_type({identifier}, '$.blobId') = 'text'",
19550 quote_ident(&table.name),
19551 ));
19552 }
19553 }
19554 if selects.is_empty() {
19555 "SELECT NULL AS blob_id WHERE 0".to_owned()
19556 } else {
19557 selects.join(" UNION ")
19558 }
19559 }
19560
19561 fn enforce_blob_cache_cap(&self) {
19564 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
19565 return;
19566 };
19567 let mut total: i64 = self
19568 .conn
19569 .query_row(
19570 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
19571 [],
19572 |row| row.get(0),
19573 )
19574 .unwrap_or(0);
19575 if total <= max_bytes {
19576 return;
19577 }
19578 let query = format!(
19579 "SELECT blob_id, byte_length FROM _syncular_blobs
19580 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
19581 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
19582 AND blob_id NOT IN ({})
19583 ORDER BY created_at_ms ASC, blob_id ASC",
19584 self.visible_blob_ids_sql(),
19585 );
19586 let candidates: Vec<(String, i64)> = {
19587 let Ok(mut stmt) = self.conn.prepare(&query) else {
19588 return;
19589 };
19590 let Ok(rows) = stmt.query_map([], |row| {
19591 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
19592 }) else {
19593 return;
19594 };
19595 rows.filter_map(Result::ok).collect()
19596 };
19597 for (blob_id, byte_length) in candidates {
19598 if total <= max_bytes {
19599 break;
19600 }
19601 let _ = self.conn.execute(
19602 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
19603 rusqlite::params![blob_id],
19604 );
19605 total -= byte_length;
19606 }
19607 }
19608
19609 fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
19610 #[cfg(feature = "bench-internals")]
19611 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
19612 if !self.schema_has_blobs() {
19613 return Ok(());
19614 }
19615 self.rebuild_overlay_if_dirty()?;
19616 self.conn
19617 .execute(
19618 &format!(
19619 "DELETE FROM _syncular_blobs
19620 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
19621 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
19622 AND blob_id NOT IN ({})",
19623 self.visible_blob_ids_sql(),
19624 ),
19625 [],
19626 )
19627 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19628 Ok(())
19629 }
19630
19631 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
19636 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
19637 return sql.clone();
19638 }
19639 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
19640 columns.push(quote_ident("_syncular_version"));
19641 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
19642 let primary_key = quote_ident(&table.primary_key);
19643 let updates = columns
19644 .iter()
19645 .filter(|column| **column != primary_key)
19646 .map(|column| format!("{column}=excluded.{column}"))
19647 .collect::<Vec<_>>()
19648 .join(", ");
19649 let sql = format!(
19650 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
19651 columns.join(", "),
19652 placeholders.join(", ")
19653 );
19654 self.insert_sql
19655 .borrow_mut()
19656 .insert(full_table.to_owned(), sql.clone());
19657 sql
19658 }
19659
19660 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
19661 self.overlay_dirty.table(table_name);
19662 self.write_row(&base_table(table_name), table_name, row, version)
19663 }
19664
19665 fn write_row(
19666 &self,
19667 full_table: &str,
19668 table_name: &str,
19669 row: &Row,
19670 version: i64,
19671 ) -> Result<(), String> {
19672 #[cfg(feature = "bench-internals")]
19673 let _phase = self.benchmark_phases.start(Phase::RowWrite);
19674 let table = self
19675 .schema
19676 .table(table_name)
19677 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
19678 let sql = self.insert_row_sql(full_table, table);
19679 let mut stmt = self
19680 .conn
19681 .prepare_cached(&sql)
19682 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19683 let params = row
19684 .iter()
19685 .map(RowParam::Cell)
19686 .chain(std::iter::once(RowParam::Version(version)));
19687 stmt.execute(rusqlite::params_from_iter(params))
19688 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19689 Ok(())
19690 }
19691
19692 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
19693 let table = self
19694 .schema
19695 .table(table_name)
19696 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
19697 self.overlay_dirty.table(table_name);
19698 let sql = format!(
19699 "DELETE FROM {} WHERE {}",
19700 base_table(table_name),
19701 row_id_predicate(table)
19702 );
19703 let mut stmt = self
19704 .conn
19705 .prepare_cached(&sql)
19706 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19707 stmt.execute(rusqlite::params![row_id])
19708 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19709 Ok(())
19710 }
19711
19712 fn has_acknowledged_rows(&self) -> bool {
19713 self.conn
19714 .query_row(
19715 "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
19716 [],
19717 |row| row.get(0),
19718 )
19719 .expect("read acknowledged intent")
19720 }
19721
19722 fn acknowledged_operations(
19723 &self,
19724 rows: Option<&HashSet<(&str, &str)>>,
19725 tables: Option<&BTreeSet<&str>>,
19726 ) -> Result<Vec<OutboxOp>, String> {
19727 let keys = rows.map(|rows| {
19728 serde_json::to_string(
19729 &rows
19730 .iter()
19731 .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
19732 .collect::<Vec<_>>(),
19733 )
19734 .expect("row keys")
19735 });
19736 let mut statement = self
19737 .conn
19738 .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")
19739 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19740 let rows = statement
19741 .query_map(
19742 rusqlite::params![
19743 keys,
19744 tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
19745 ],
19746 |row| {
19747 #[cfg(test)]
19748 self.acknowledged_replay_count
19749 .set(self.acknowledged_replay_count.get() + 1);
19750 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
19751 },
19752 )
19753 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19754 rows.map(|row| {
19755 let (op, intent) =
19756 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19757 let op: CommitOperation =
19758 serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
19759 let table = self
19760 .schema
19761 .table(&op.table)
19762 .ok_or_else(|| "sync.unknown_table".to_owned())?;
19763 let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
19764 Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
19765 } else {
19766 op.values
19767 };
19768 Ok(OutboxOp {
19769 table: op.table,
19770 row_id: op.row_id,
19771 upsert: op.op == "upsert",
19772 base_version: op.base_version,
19773 values,
19774 })
19775 })
19776 .collect()
19777 }
19778
19779 fn drop_acknowledged_rows(
19780 &self,
19781 matches: impl Fn(&str, &Map<String, Value>) -> bool,
19782 ) -> Result<Vec<OutboxCommit>, String> {
19783 let mut statement = self
19784 .conn
19785 .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
19786 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19787 let rows = statement
19788 .query_map([], |row| {
19789 Ok((
19790 row.get::<_, String>(0)?,
19791 row.get::<_, String>(1)?,
19792 row.get::<_, String>(2)?,
19793 row.get::<_, String>(3)?,
19794 ))
19795 })
19796 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19797 let mut doomed = HashSet::new();
19798 for row in rows {
19799 let (id, table, intent, row_id) =
19800 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19801 let values =
19802 serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
19803 let base_matches = self
19804 .schema
19805 .table(&table)
19806 .map(|schema| self.stored_row(schema, &row_id, true))
19807 .transpose()?
19808 .flatten()
19809 .is_some_and(|(base, _)| {
19810 let schema = self.schema.table(&table).expect("acknowledged table");
19811 let values = schema
19812 .columns
19813 .iter()
19814 .zip(base.iter())
19815 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
19816 .collect();
19817 matches(&table, &values)
19818 });
19819 if matches(&table, &values) || base_matches {
19820 doomed.insert(id);
19821 }
19822 }
19823 let mut dropped = Vec::new();
19824 for id in doomed {
19825 let mut statement = self
19826 .conn
19827 .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
19828 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19829 let operations = statement
19830 .query_map([&id], |row| row.get::<_, String>(0))
19831 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19832 let mut ops = Vec::new();
19833 for operation in operations {
19834 let json = operation
19835 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19836 let op: CommitOperation =
19837 serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
19838 self.overlay_dirty.table(&op.table);
19839 ops.push(OutboxOp {
19840 table: op.table,
19841 row_id: op.row_id,
19842 upsert: op.op == "upsert",
19843 base_version: op.base_version,
19844 values: op.values,
19845 });
19846 }
19847 dropped.push(OutboxCommit {
19848 client_commit_id: id.clone(),
19849 ops,
19850 });
19851 self.conn
19852 .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
19853 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19854 }
19855 Ok(dropped)
19856 }
19857
19858 fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
19861 if self.overlay_dirty.get() {
19862 self.rebuild_overlay()?;
19863 }
19864 Ok(())
19865 }
19866
19867 fn rebuild_overlay(&mut self) -> Result<(), String> {
19871 #[cfg(feature = "bench-internals")]
19872 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
19873 #[cfg(test)]
19874 self.overlay_rebuild_count
19875 .set(self.overlay_rebuild_count.get() + 1);
19876 let dirty = self.overlay_dirty.snapshot();
19877 let tables: Vec<_> = self
19878 .schema
19879 .tables
19880 .iter()
19881 .filter(|table| {
19882 dirty
19883 .as_ref()
19884 .is_none_or(|names| names.is_empty() || names.contains(&table.name))
19885 })
19886 .cloned()
19887 .collect();
19888 let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
19889 self.begin_observation("syncular_overlay")?;
19890 let rebuilt = (|| {
19891 for table in &tables {
19892 for index in &table.fts_indexes {
19893 self.drop_fts_triggers(index)?;
19894 }
19895 let visible = visible_table(&table.name);
19896 let base = base_table(&table.name);
19897 self.conn
19898 .execute_batch(&format!(
19899 "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
19900 ))
19901 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19902 }
19903 let acknowledged = self.acknowledged_operations(None, Some(&names))?;
19904 self.apply_outbox_ops(
19905 acknowledged
19906 .iter()
19907 .filter(|op| names.contains(op.table.as_str())),
19908 )?;
19909 for (commit, initial) in &self.failed_commits {
19910 for operation in &commit.ops {
19911 if !names.contains(operation.table.as_str()) || !operation.upsert {
19912 continue;
19913 }
19914 if let Some(table) = self.schema.table(&operation.table) {
19915 if self.visible_row(table, &operation.row_id)?.is_none()
19916 && operation.values.as_ref().is_some_and(|values| {
19917 table
19918 .columns
19919 .iter()
19920 .all(|column| values.contains_key(&column.name))
19921 })
19922 {
19923 let key = serde_json::to_string(&(
19924 operation.table.clone(),
19925 operation.row_id.clone(),
19926 ))
19927 .expect("row key serialization");
19928 if let Some(Value::Object(values)) = initial.get(&key) {
19929 self.apply_outbox_ops(&[OutboxOp {
19930 values: Some(values.clone()),
19931 ..operation.clone()
19932 }])?;
19933 }
19934 }
19935 }
19936 }
19937 self.apply_outbox_ops(
19938 commit
19939 .ops
19940 .iter()
19941 .filter(|op| names.contains(op.table.as_str())),
19942 )?;
19943 }
19944 for commit in &self.outbox {
19945 self.apply_outbox_ops(
19946 commit
19947 .ops
19948 .iter()
19949 .filter(|op| names.contains(op.table.as_str())),
19950 )?;
19951 }
19952 for table in &tables {
19953 for index in &table.fts_indexes {
19954 self.rebuild_fts_projection(table, index)?;
19955 self.create_fts_triggers(table, index)?;
19956 }
19957 }
19958 self.conn
19959 .execute_batch("RELEASE syncular_overlay")
19960 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19961 Ok(())
19962 })();
19963 if rebuilt.is_err() {
19964 self.conn
19965 .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
19966 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19967 } else {
19968 self.overlay_dirty.set(false);
19969 }
19970 rebuilt
19971 }
19972
19973 fn apply_outbox_ops<'a>(
19978 &self,
19979 ops: impl IntoIterator<Item = &'a OutboxOp>,
19980 ) -> Result<(), String> {
19981 #[cfg(feature = "bench-internals")]
19982 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
19983 for op in ops {
19984 #[cfg(feature = "bench-internals")]
19985 if let Some(phase) = &mut phase {
19986 phase.unit();
19987 }
19988 if op.upsert {
19989 if let (Some(table), Some(values)) = (
19990 self.schema
19991 .table(&op.table)
19992 .filter(|table| table.indexes.iter().any(|index| index.unique)),
19993 op.values.as_ref(),
19994 ) {
19995 let mut intended: Map<String, Value> = self
19996 .visible_row(table, &op.row_id)?
19997 .map(|(row, _)| {
19998 table
19999 .columns
20000 .iter()
20001 .zip(&row)
20002 .map(|(column, value)| {
20003 (column.name.clone(), column_value_to_json(value))
20004 })
20005 .collect()
20006 })
20007 .unwrap_or_default();
20008 intended.extend(values.clone());
20009 if !self.unique_conflicts(table, &intended, false)?.is_empty() {
20010 continue;
20011 }
20012 }
20013 }
20014 self.apply_outbox_op(op)?;
20015 }
20016 Ok(())
20017 }
20018
20019 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
20020 let Some(table) = self.schema.table(&op.table) else {
20021 return Ok(());
20022 };
20023 let visible = visible_table(&table.name);
20024 if !op.upsert {
20025 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
20026 self.conn
20027 .prepare_cached(&sql)
20028 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
20029 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20030 return Ok(());
20031 }
20032 let Some(values) = op.values.as_ref() else {
20033 return Ok(());
20034 };
20035 let full = table
20040 .columns
20041 .iter()
20042 .all(|column| values.contains_key(&column.name));
20043 let local = self.visible_row(table, &op.row_id)?;
20044 if local.is_none() && !full {
20045 return Ok(());
20046 }
20047 let mut row: Row = Vec::with_capacity(table.columns.len());
20048 for (index, column) in table.columns.iter().enumerate() {
20049 let incoming = match values.get(&column.name) {
20050 Some(raw) => json_to_column_value(
20051 if raw.get("$bytes").is_some()
20052 && table
20053 .encrypted_columns
20054 .iter()
20055 .any(|encrypted| encrypted.index == index)
20056 {
20057 &table.wire_columns[index]
20058 } else {
20059 column
20060 },
20061 Some(raw),
20062 ),
20063 None => Ok(local
20064 .as_ref()
20065 .and_then(|(values, _)| values.get(index).cloned())
20066 .flatten()),
20067 };
20068 row.push(incoming.map_err(|_| {
20069 "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
20070 })?);
20071 }
20072 let version = local.as_ref().map_or(-1, |(_, version)| *version);
20073 self.write_row(&visible, &table.name, &row, version)
20074 .map_err(|error| {
20075 if error.starts_with("UNIQUE constraint failed:") {
20076 "sync.constraint_violation: local write violates a unique constraint".to_owned()
20077 } else {
20078 error
20079 }
20080 })
20081 }
20082
20083 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
20086 self.stored_row(table, row_id, false)
20087 }
20088
20089 fn stored_row(
20090 &self,
20091 table: &TableSchema,
20092 row_id: &str,
20093 base: bool,
20094 ) -> Result<Option<(Row, i64)>, String> {
20095 let sql = format!(
20096 "SELECT * FROM {} WHERE {}",
20097 if base {
20098 base_table(&table.name)
20099 } else {
20100 visible_table(&table.name)
20101 },
20102 row_id_predicate(table)
20103 );
20104 let mut stmt = self
20105 .conn
20106 .prepare_cached(&sql)
20107 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20108 let mut rows = stmt
20109 .query(rusqlite::params![row_id])
20110 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20111 let Some(row) = rows
20112 .next()
20113 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
20114 else {
20115 return Ok(None);
20116 };
20117 let mut values = Vec::with_capacity(table.columns.len());
20118 for (index, column) in table.columns.iter().enumerate() {
20119 let raw = row
20120 .get_ref(index)
20121 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20122 if let rusqlite::types::ValueRef::Blob(bytes) = raw {
20123 if table
20124 .encrypted_columns
20125 .iter()
20126 .any(|encrypted| encrypted.index == index)
20127 {
20128 values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
20129 continue;
20130 }
20131 }
20132 let json = sql_ref_to_json(column, raw);
20133 values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
20134 "sync.local_corrupt: persisted local row value is invalid".to_owned()
20135 })?);
20136 }
20137 let version: i64 = row
20138 .get(table.columns.len())
20139 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20140 Ok(Some((values, version)))
20141 }
20142
20143 #[must_use]
20147 pub fn realtime_policy(&self) -> RealtimePolicy {
20148 self.realtime_policy
20149 }
20150
20151 #[must_use]
20153 pub fn realtime_state(&self) -> RealtimeState {
20154 self.realtime_state
20155 }
20156
20157 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
20160 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
20161 return Err(
20162 "sync.invalid_request: disconnect realtime before changing the realtime policy"
20163 .to_owned(),
20164 );
20165 }
20166 self.realtime_policy = policy;
20167 match policy {
20168 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
20169 RealtimePolicy::Required | RealtimePolicy::Optional
20170 if self.realtime_state == RealtimeState::Disabled =>
20171 {
20172 self.set_realtime_state(RealtimeState::Disconnected, None);
20173 }
20174 RealtimePolicy::Required | RealtimePolicy::Optional => {}
20175 }
20176 Ok(())
20177 }
20178
20179 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
20182 self.realtime_state = state;
20183 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
20184 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
20185 self.realtime_reason_code = None;
20186 self.realtime_retry_delay_ms = None;
20187 }
20188 }
20189
20190 #[must_use]
20191 fn realtime_connected(&self) -> bool {
20192 self.realtime_state == RealtimeState::Connected
20193 }
20194
20195 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
20196 if !self.transport_enabled {
20197 return Err("sync.offline: transport gate is closed".into());
20198 }
20199 if self.realtime_policy == RealtimePolicy::Off {
20200 return Err(
20201 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
20202 );
20203 }
20204 if self.realtime_connected() {
20205 return Ok(());
20206 }
20207 self.realtime_state = RealtimeState::Connecting;
20208 match transport.realtime_connect_for_client(&self.client_id) {
20209 Ok(()) => {
20210 self.set_realtime_state(RealtimeState::Connected, None);
20211 Ok(())
20212 }
20213 Err(error) => {
20214 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
20215 Err(format!("{}: {}", error.code, error.message))
20216 }
20217 }
20218 }
20219
20220 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
20221 self.cancel_sync_round();
20222 if self.realtime_state == RealtimeState::Disconnected
20225 || self.realtime_state == RealtimeState::Disabled
20226 {
20227 return;
20228 }
20229 let _ = transport.realtime_close();
20230 self.set_realtime_state(
20231 if self.realtime_policy == RealtimePolicy::Off {
20232 RealtimeState::Disabled
20233 } else {
20234 RealtimeState::Disconnected
20235 },
20236 None,
20237 );
20238 self.presence.clear(); }
20240
20241 pub fn realtime_lost(&mut self, transport: &mut dyn Transport, error: &TransportError) {
20247 if self.realtime_state != RealtimeState::Connected {
20248 return;
20249 }
20250 self.cancel_sync_round();
20251 let _ = transport.realtime_close();
20252 self.set_realtime_state(RealtimeState::Lost, Some(&error.code));
20253 self.realtime_retry_delay_ms = Some(self.schedule_background_retry());
20254 self.presence.clear();
20255 }
20256
20257 pub fn set_presence(
20263 &mut self,
20264 transport: &mut dyn Transport,
20265 scope_key: &str,
20266 doc: Option<&Value>,
20267 ) -> Result<(), String> {
20268 if !self.transport_enabled {
20269 return Err("sync.offline: transport gate is closed".into());
20270 }
20271 if !self.realtime_connected() {
20272 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
20273 }
20274 let text = encode_presence_publish(scope_key, doc);
20275 transport
20276 .realtime_send(&text)
20277 .map_err(|e| format!("{}: {}", e.code, e.message))
20278 }
20279
20280 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
20282 self.presence
20283 .get(scope_key)
20284 .map(|peers| peers.values().cloned().collect())
20285 .unwrap_or_default()
20286 }
20287
20288 fn apply_presence(
20290 &mut self,
20291 scope_key: String,
20292 kind: Option<PresenceKind>,
20293 actor_id: Option<String>,
20294 client_id: Option<String>,
20295 doc: Option<Value>,
20296 error: Option<String>,
20297 ) {
20298 if error.is_some() {
20301 return;
20302 }
20303 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
20304 return;
20305 };
20306 let peer_key = format!("{actor_id} {client_id}");
20307 match kind {
20308 PresenceKind::Leave => {
20309 if let Some(peers) = self.presence.get_mut(&scope_key) {
20310 peers.remove(&peer_key);
20311 if peers.is_empty() {
20312 self.presence.remove(&scope_key);
20313 }
20314 }
20315 }
20316 _ => {
20317 let doc = match doc {
20318 Some(Value::Object(_)) => doc.unwrap(),
20319 _ => return,
20320 };
20321 self.presence.entry(scope_key).or_default().insert(
20322 peer_key,
20323 PresencePeer {
20324 actor_id,
20325 client_id,
20326 doc,
20327 },
20328 );
20329 }
20330 }
20331 }
20332
20333 pub fn on_realtime_text(&mut self, text: &str) {
20335 if self.stopped || !self.transport_enabled || self.security_preflight {
20336 return;
20337 }
20338 match parse_control(text) {
20339 Ok(ControlMessage::Hello { requires_sync, .. }) => {
20340 if requires_sync {
20341 self.set_sync_needed(true, true);
20343 }
20344 }
20345 Ok(ControlMessage::Presence {
20346 scope_key,
20347 kind,
20348 actor_id,
20349 client_id,
20350 doc,
20351 error,
20352 ..
20353 }) => {
20354 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
20355 }
20356 Ok(ControlMessage::Wake { .. }) => {
20357 self.set_sync_needed(true, true);
20359 }
20360 _ => {}
20361 }
20362 }
20363
20364 pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
20368 let mut transport = crate::round::DownloadResults::default();
20369 self.on_realtime_binary(&mut transport, bytes);
20370 transport.controls
20371 }
20372
20373 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
20374 if self.stopped || !self.transport_enabled || self.security_preflight {
20375 return;
20376 }
20377 #[cfg(feature = "bench-internals")]
20378 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
20379 let message = match decode_message(bytes) {
20380 Ok(m) if m.msg_kind == MsgKind::Response => m,
20381 _ => {
20382 self.set_sync_needed(true, true);
20383 return;
20384 }
20385 };
20386 #[cfg(feature = "bench-internals")]
20387 drop(decode_phase);
20388 let mut frames = message.frames.into_iter();
20389 let mut applied_cursor: Option<i64> = None;
20390 let mut any_covered = false;
20391 let mut dropped = self.begin_coalesced_apply().is_err();
20392 while let Some(frame) = frames.next() {
20393 let Frame::SubStart {
20394 id,
20395 status,
20396 effective_scopes,
20397 ..
20398 } = frame
20399 else {
20400 continue;
20401 };
20402 let mut body = Vec::new();
20403 let mut next_cursor: Option<i64> = None;
20404 for inner in frames.by_ref() {
20405 match inner {
20406 Frame::SubEnd {
20407 next_cursor: nc, ..
20408 } => {
20409 next_cursor = Some(nc);
20410 break;
20411 }
20412 Frame::Unknown { .. } => {}
20413 other => body.push(other),
20414 }
20415 }
20416 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
20417 dropped = true;
20418 continue;
20419 };
20420 let sub = &self.subs[sub_index];
20421 if status != SubStatus::Active
20424 || sub.state != SubState::Active
20425 || sub.bootstrap_state.is_some()
20426 || !sub.synced_once
20427 {
20428 dropped = true;
20429 continue;
20430 }
20431 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
20432 any_covered = true;
20434 continue;
20435 }
20436 let previous_effective = self.subs[sub_index].effective.clone();
20437 let previous_cursor = self.subs[sub_index].cursor;
20438 self.subs[sub_index].effective = Some(effective_scopes);
20439 let mut failed = false;
20440 for inner in body {
20441 match inner {
20442 Frame::Commit {
20443 tables,
20444 changes,
20445 commit_seq,
20446 ..
20447 } => {
20448 if self
20449 .apply_commit_frame(&tables, &changes, Some(commit_seq))
20450 .is_err()
20451 {
20452 failed = true;
20453 break;
20454 }
20455 }
20456 _ => {
20457 failed = true;
20458 break;
20459 }
20460 }
20461 }
20462 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
20463 self.apply_sub_end(sub_index, cursor, None)
20464 .ok()
20465 .map(|()| cursor)
20466 });
20467 let Some(next_cursor) = committed else {
20468 self.subs[sub_index].effective = previous_effective;
20469 self.subs[sub_index].cursor = previous_cursor;
20470 dropped = true;
20471 continue;
20472 };
20473 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
20474 }
20475 if self.finish_coalesced_apply().is_err() {
20478 applied_cursor = None;
20479 dropped = true;
20480 }
20481 if let Some(cursor) = applied_cursor {
20482 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
20484 let _ = transport.realtime_send(&ack);
20485 } else if !any_covered || dropped {
20486 self.set_sync_needed(true, true);
20488 }
20489 }
20490
20491 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
20495 if !self.realtime_connected() {
20496 return;
20497 }
20498 let floor = self
20499 .subs
20500 .iter()
20501 .filter(|s| {
20502 s.state == SubState::Active
20503 && s.bootstrap_state.is_none()
20504 && s.synced_once
20505 && s.cursor >= 0
20506 })
20507 .map(|s| s.cursor)
20508 .min();
20509 if let Some(cursor) = floor {
20510 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
20511 let _ = transport.realtime_send(&ack);
20512 }
20513 }
20514}
20515
20516#[cfg(test)]
20517mod previous_version_wiring_tests {
20518 use super::*;
20519 use serde_json::{json, Value};
20520
20521 fn previous_version_schema(version: i32) -> Value {
20522 json!({
20523 "version": version,
20524 "tables": [{
20525 "name": "tasks",
20526 "primaryKey": "id",
20527 "columns": [
20528 { "name": "id", "type": "string", "nullable": false },
20529 { "name": "note", "type": "string", "nullable": true }
20530 ],
20531 "scopes": []
20532 }]
20533 })
20534 }
20535
20536 fn previous_version_limits() -> crate::api::ClientLimits {
20537 crate::api::ClientLimits {
20538 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
20539 enabled: true,
20540 ..Default::default()
20541 }),
20542 ..Default::default()
20543 }
20544 }
20545
20546 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
20548 previous_version_limits()
20549 .previous_version_context
20550 .expect("configured")
20551 }
20552
20553 fn recapture_previous_version(client: &SyncClient) {
20556 let replica_path = client
20557 .previous_version_replica_path()
20558 .expect("file-backed replica");
20559 let outcome = crate::previous_version::capture_previous_version_from_replica(
20560 &client.conn,
20561 &replica_path,
20562 2,
20563 2,
20564 &previous_version_config(),
20565 1,
20566 )
20567 .expect("capture");
20568 assert!(matches!(
20569 outcome,
20570 crate::previous_version::CaptureOutcome::Captured(_)
20571 ));
20572 }
20573
20574 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
20575 Subscription {
20576 id: "wiring-sub".to_owned(),
20577 table: "tasks".to_owned(),
20578 requested: Vec::new(),
20579 params: None,
20580 cursor,
20581 bootstrap_state: bootstrap_state.map(str::to_owned),
20582 state,
20583 reason_code: None,
20584 effective: None,
20585 synced_once: false,
20586 }
20587 }
20588
20589 #[test]
20590 fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
20591 let path =
20592 std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
20593 let path_str = path.to_str().expect("temp path");
20594 let schema = previous_version_schema(3);
20595 let mut older = previous_version_schema(2);
20596 older["tables"][0]["columns"] =
20597 json!([{ "name": "id", "type": "string", "nullable": false }]);
20598 let mut client = SyncClient::open_path(
20599 "downgrade".into(),
20600 &previous_version_schema(1),
20601 previous_version_limits(),
20602 path_str,
20603 )
20604 .expect("fresh");
20605 client
20606 .mutate(vec![Mutation::Upsert {
20607 table: "tasks".into(),
20608 values: Map::from_iter([
20609 ("id".into(), json!("accepted")),
20610 ("note".into(), json!("old")),
20611 ]),
20612 base_version: None,
20613 }])
20614 .expect("v1 write");
20615 client
20616 .recreate_with_schema(&schema)
20617 .expect("upgrade captures context");
20618 let commit = client
20619 .mutate(vec![Mutation::Upsert {
20620 table: "tasks".into(),
20621 values: Map::from_iter([
20622 ("id".into(), json!("queued")),
20623 ("note".into(), json!("v3-only")),
20624 ]),
20625 base_version: None,
20626 }])
20627 .expect("queued v3 write");
20628 let changes = client.conn.total_changes();
20629 let outbox = client.pending_commit_ids();
20630 assert!(outbox.contains(&commit));
20631 let descriptor = client.get_meta("localSchemaDescriptor");
20632 let container = format!(
20633 "{path_str}{}",
20634 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
20635 );
20636 let context = std::fs::read(&container).expect("captured context");
20637 assert!(client
20638 .recreate_with_schema(&older)
20639 .expect_err("refuse recreate")
20640 .starts_with("client.schema_downgrade:"));
20641 assert_eq!(client.schema.version, 3);
20642 assert_eq!(client.conn.total_changes(), changes);
20643 assert_eq!(client.pending_commit_ids(), outbox);
20644 assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
20645 assert_eq!(
20646 client
20647 .conn
20648 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
20649 row.get::<_, String>(0)
20650 })
20651 .expect("queued row"),
20652 "v3-only"
20653 );
20654 assert_eq!(std::fs::read(&container).expect("context remains"), context);
20655 drop(client);
20656 let before = std::fs::read(&path).expect("replica bytes");
20657 for entry in 0..3 {
20660 let error = match entry {
20661 0 => SyncClient::open_path(
20662 "downgrade".into(),
20663 &older,
20664 previous_version_limits(),
20665 path_str,
20666 ),
20667 1 => SyncClient::open_path_with_identity(
20668 None,
20669 &older,
20670 previous_version_limits(),
20671 path_str,
20672 ),
20673 _ => SyncClient::with_connection(
20674 "downgrade".into(),
20675 &older,
20676 previous_version_limits(),
20677 Connection::open(&path).expect("connection"),
20678 ),
20679 }
20680 .err()
20681 .expect("refuse open");
20682 assert!(error.starts_with("client.schema_downgrade:"));
20683 let conn = Connection::open(&path).expect("inspect");
20684 assert_eq!(
20685 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
20686 .expect("journal mode"),
20687 "delete"
20688 );
20689 drop(conn);
20690 assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
20691 assert_eq!(
20692 std::fs::read(&container).expect("context unchanged"),
20693 context
20694 );
20695 }
20696 let reopened = SyncClient::open_path(
20697 "downgrade".into(),
20698 &schema,
20699 previous_version_limits(),
20700 path_str,
20701 )
20702 .expect("compatible reopen");
20703 assert_eq!(reopened.pending_commit_ids(), outbox);
20704 assert_eq!(
20705 reopened
20706 .conn
20707 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
20708 row.get::<_, String>(0)
20709 })
20710 .expect("queued row remains"),
20711 "v3-only"
20712 );
20713 reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
20714 reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
20715 drop(reopened);
20716 let legacy = SyncClient::open_path(
20717 "downgrade".into(),
20718 &schema,
20719 previous_version_limits(),
20720 path_str,
20721 )
20722 .expect("legacy no-marker reopen");
20723 assert_eq!(legacy.pending_commit_ids(), outbox);
20724 assert!(!legacy.upgrading());
20725 assert_eq!(
20726 legacy
20727 .conn
20728 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
20729 row.get::<_, String>(0)
20730 })
20731 .expect("legacy row remains"),
20732 "v3-only"
20733 );
20734 drop(legacy);
20735 std::fs::remove_file(path).expect("remove replica");
20736 let _ = std::fs::remove_file(container);
20737 }
20738
20739 #[test]
20740 fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
20741 for marker in [
20742 "''",
20743 "'bad'",
20744 "'0'",
20745 "'-1'",
20746 "'2.5'",
20747 "'03'",
20748 "'3\n'",
20749 "' 3'",
20750 "'+3'",
20751 "'3e0'",
20752 "'2147483648'",
20753 "NULL",
20754 "X'33'",
20755 ] {
20756 let conn = Connection::open_in_memory().expect("connection");
20757 conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
20758 let error = SyncClient::with_connection(
20759 "marker".into(),
20760 &previous_version_schema(3),
20761 ClientLimits::default(),
20762 conn,
20763 )
20764 .err()
20765 .expect("refuse marker");
20766 assert!(
20767 error.starts_with("sync.local_corrupt:"),
20768 "{marker}: {error}"
20769 );
20770 }
20771 for fault in [
20772 "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
20773 "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
20774 ] {
20775 let mut client = SyncClient::new(
20776 "marker".into(),
20777 &previous_version_schema(3),
20778 ClientLimits::default(),
20779 )
20780 .expect("fresh");
20781 let commit = client
20782 .mutate(vec![Mutation::Upsert {
20783 table: "tasks".into(),
20784 values: Map::from_iter([("id".into(), json!("queued"))]),
20785 base_version: None,
20786 }])
20787 .expect("queued write");
20788 client.conn.execute_batch(fault).expect("marker fault");
20789 let changes = client.conn.total_changes();
20790 let error = client
20791 .recreate_with_schema(&previous_version_schema(4))
20792 .expect_err("refuse corrupt marker");
20793 assert!(error.starts_with("sync.local_corrupt:"));
20794 assert_eq!(client.conn.total_changes(), changes);
20795 assert_eq!(client.schema.version, 3);
20796 assert_eq!(client.pending_commit_ids(), vec![commit]);
20797 }
20798 for setup in [
20799 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
20800 "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
20801 ] {
20802 let path =
20803 std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
20804 let conn = Connection::open(&path).expect("connection");
20805 conn.execute_batch(setup)
20806 .expect("unreadable metadata fixture");
20807 drop(conn);
20808 let before = std::fs::read(&path).expect("before");
20809 let error = SyncClient::open_path_with_identity(
20810 None,
20811 &previous_version_schema(3),
20812 ClientLimits::default(),
20813 path.to_str().expect("path"),
20814 )
20815 .err()
20816 .expect("refuse unreadable marker");
20817 assert!(error.starts_with("sync.local_corrupt:"));
20818 assert_eq!(std::fs::read(&path).expect("after"), before);
20819 std::fs::remove_file(path).expect("remove fixture");
20820 }
20821 }
20822
20823 std::thread_local! {
20824 static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
20827 const { RefCell::new(None) };
20828 }
20829
20830 fn commit_concurrent_upgrade(attempts: i32) -> bool {
20831 CONCURRENT_UPGRADE.with(|slot| {
20832 if let Some(banner) = slot.borrow_mut().take() {
20833 banner
20834 .execute_batch(
20835 "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
20836 )
20837 .expect("commit the concurrent upgrade");
20838 }
20839 });
20840 attempts < 10
20841 }
20842
20843 fn journal_mode(conn: &Connection) -> String {
20844 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
20845 .expect("journal mode")
20846 }
20847
20848 fn temp_replica(label: &str) -> std::path::PathBuf {
20849 std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
20850 }
20851
20852 #[test]
20853 fn file_identity_constructor_requires_the_returned_wal_mode() {
20854 for path in [":memory:", ""] {
20855 let error = SyncClient::open_path_with_identity(
20856 None,
20857 &previous_version_schema(1),
20858 ClientLimits::default(),
20859 path,
20860 )
20861 .err()
20862 .expect("temporary SQLite databases cannot enter WAL mode");
20863 assert_eq!(
20864 error,
20865 "sync.invalid_request: file-backed client requires WAL journal mode"
20866 );
20867 }
20868 let path = temp_replica("confirmed-wal");
20869 let client = SyncClient::open_path_with_identity(
20870 None,
20871 &previous_version_schema(1),
20872 ClientLimits::default(),
20873 path.to_str().unwrap(),
20874 )
20875 .unwrap();
20876 assert_eq!(journal_mode(&client.conn), "wal");
20877 drop(client);
20878 std::fs::remove_file(path).unwrap();
20879 }
20880
20881 #[test]
20882 fn non_positive_generated_schema_versions_are_refused_before_storage() {
20883 for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
20884 let mut schema = previous_version_schema(1);
20885 schema["version"] = version.clone();
20886 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
20887 .err()
20888 .expect("refuse an out-of-range generated version");
20889 assert_eq!(
20890 error,
20891 "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
20892 );
20893 let path = temp_replica("bad-version");
20895 let path_str = path.to_str().expect("path");
20896 for identity in [false, true] {
20897 let error = if identity {
20898 SyncClient::open_path_with_identity(
20899 None,
20900 &schema,
20901 ClientLimits::default(),
20902 path_str,
20903 )
20904 } else {
20905 SyncClient::open_path(
20906 "bad-version".into(),
20907 &schema,
20908 ClientLimits::default(),
20909 path_str,
20910 )
20911 }
20912 .err()
20913 .expect("refuse before storage");
20914 assert!(
20915 error.starts_with("sync.invalid_request:"),
20916 "{version}: {error}"
20917 );
20918 assert!(
20919 !path.exists(),
20920 "{version}: a refused constructor creates no replica file"
20921 );
20922 }
20923 }
20924 let mut schema = previous_version_schema(1);
20926 schema
20927 .as_object_mut()
20928 .expect("schema object")
20929 .remove("version");
20930 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
20931 .err()
20932 .expect("refuse a missing version");
20933 assert!(error.starts_with("sync.invalid_request:"), "{error}");
20934 }
20935
20936 #[test]
20937 fn a_duplicated_schema_marker_is_refused_without_a_reset() {
20938 let path = temp_replica("duplicate-marker");
20939 let path_str = path.to_str().expect("path");
20940 {
20941 let conn = Connection::open(path_str).expect("fixture");
20944 conn.execute_batch(
20945 "CREATE TABLE _syncular_meta(key TEXT, value);
20946 INSERT INTO _syncular_meta VALUES
20947 ('localSchemaVersion', '1'),
20948 ('localSchemaVersion', '2'),
20949 ('clientId', 'duplicate');",
20950 )
20951 .expect("duplicate marker fixture");
20952 }
20953 let error = SyncClient::open_path_with_identity(
20954 None,
20955 &previous_version_schema(3),
20956 ClientLimits::default(),
20957 path_str,
20958 )
20959 .err()
20960 .expect("refuse a duplicated marker");
20961 assert!(error.starts_with("sync.local_corrupt:"), "{error}");
20962 let probe = Connection::open(path_str).expect("probe");
20963 assert_eq!(
20964 probe
20965 .query_row(
20966 "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
20967 [],
20968 |row| row.get::<_, i64>(0),
20969 )
20970 .expect("marker rows"),
20971 2,
20972 "the duplicated rows are left for the operator, not collapsed"
20973 );
20974 assert_eq!(
20975 probe
20976 .query_row(
20977 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
20978 [],
20979 |row| row.get::<_, i64>(0),
20980 )
20981 .expect("synced tables"),
20982 0,
20983 "a duplicated marker never drives the reset that recreates v3 tables"
20984 );
20985 assert_eq!(
20986 journal_mode(&probe),
20987 "delete",
20988 "the refusal leaves the journal mode alone"
20989 );
20990 drop(probe);
20991 std::fs::remove_file(path).expect("remove replica");
20992 }
20993
20994 #[test]
20995 fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
20996 let path = temp_replica("refused-wal");
20997 let path_str = path.to_str().expect("path");
20998 drop(
21001 SyncClient::open_path(
21002 "owner".into(),
21003 &previous_version_schema(2),
21004 ClientLimits::default(),
21005 path_str,
21006 )
21007 .expect("v2 replica"),
21008 );
21009 let before = std::fs::read(&path).expect("before");
21010 let error = SyncClient::open_path_with_identity(
21011 Some("intruder".into()),
21012 &previous_version_schema(2),
21013 ClientLimits::default(),
21014 path_str,
21015 )
21016 .err()
21017 .expect("refuse the identity mismatch");
21018 assert!(error.starts_with("client.identity_mismatch:"), "{error}");
21019 let probe = Connection::open(path_str).expect("probe");
21020 assert_eq!(
21021 journal_mode(&probe),
21022 "delete",
21023 "a refused open must not persist WAL"
21024 );
21025 assert_eq!(std::fs::read(&path).expect("after"), before);
21026 assert_eq!(
21027 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
21028 Some("2")
21029 );
21030 drop(probe);
21031 std::fs::remove_file(path).expect("remove replica");
21032 }
21033
21034 #[test]
21035 fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
21036 let path = temp_replica("marker-race");
21037 let path_str = path.to_str().expect("path");
21038 drop(
21039 SyncClient::open_path_with_identity(
21040 Some("stale".into()),
21041 &previous_version_schema(1),
21042 ClientLimits::default(),
21043 path_str,
21044 )
21045 .expect("v1 replica"),
21046 );
21047 let banner = Connection::open(path_str).expect("banner");
21051 banner
21052 .execute_batch(
21053 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
21054 )
21055 .expect("pending upgrade");
21056 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
21057 let conn = Connection::open(path_str).expect("stale connection");
21058 conn.busy_handler(Some(commit_concurrent_upgrade))
21059 .expect("busy handler");
21060 let error = SyncClient::with_connection(
21061 "stale".into(),
21062 &previous_version_schema(1),
21063 ClientLimits::default(),
21064 conn,
21065 )
21066 .err()
21067 .expect("a concurrent upgrade must refuse the stale open");
21068 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
21069 let probe = Connection::open(path_str).expect("probe");
21070 assert_eq!(
21071 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
21072 Some("2"),
21073 "the concurrent upgrade survives"
21074 );
21075 drop(probe);
21076 std::fs::remove_file(path).expect("remove replica");
21077 }
21078
21079 #[test]
21080 fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
21081 let path = temp_replica("recreate-race");
21082 let path_str = path.to_str().expect("path");
21083 let mut client = SyncClient::open_path_with_identity(
21084 Some("stale".into()),
21085 &previous_version_schema(1),
21086 ClientLimits::default(),
21087 path_str,
21088 )
21089 .expect("v1 replica");
21090 client
21091 .mutate(vec![Mutation::Upsert {
21092 table: "tasks".into(),
21093 values: Map::from_iter([("id".into(), json!("queued"))]),
21094 base_version: None,
21095 }])
21096 .expect("queued write");
21097 let commit = client.pending_commit_ids();
21098 let banner = Connection::open(path_str).expect("banner");
21099 banner
21100 .execute_batch(
21101 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
21102 )
21103 .expect("pending upgrade");
21104 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
21105 client
21106 .conn
21107 .busy_handler(Some(commit_concurrent_upgrade))
21108 .expect("busy handler");
21109 let error = client
21110 .recreate_with_schema(&previous_version_schema(1))
21111 .expect_err("a concurrent upgrade must refuse the stale recreate");
21112 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
21113 assert_eq!(client.schema.version, 1, "the compiled schema is restored");
21114 assert_eq!(
21115 client.pending_commit_ids(),
21116 commit,
21117 "the outbox is preserved"
21118 );
21119 let probe = Connection::open(path_str).expect("probe");
21120 assert_eq!(
21121 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
21122 Some("2")
21123 );
21124 drop(probe);
21125 drop(client);
21126 std::fs::remove_file(path).expect("remove replica");
21127 }
21128
21129 #[test]
21130 fn failed_schema_record_writes_roll_back_recreation() {
21131 for key in [
21132 LOCAL_SCHEMA_VERSION_KEY,
21133 crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
21134 ] {
21135 let mut client = SyncClient::new(
21136 "schema-record".into(),
21137 &previous_version_schema(1),
21138 ClientLimits::default(),
21139 )
21140 .expect("v1");
21141 let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
21142 let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
21143 client.conn.execute_batch(&format!("CREATE TRIGGER refuse_schema_record BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{key}' BEGIN SELECT RAISE(ABORT, 'refuse schema record'); END")).expect("fault");
21144 client
21145 .recreate_with_schema(&previous_version_schema(2))
21146 .expect_err("both record writes must propagate failure");
21147 assert_eq!(client.schema.version, 1);
21148 assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
21149 assert_eq!(
21150 client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
21151 descriptor
21152 );
21153 client
21154 .conn
21155 .execute_batch("DROP TRIGGER refuse_schema_record")
21156 .expect("remove fault");
21157 client
21158 .recreate_with_schema(&previous_version_schema(2))
21159 .expect("retry");
21160 assert_eq!(client.schema.version, 2);
21161 }
21162 }
21163
21164 #[test]
21165 fn a_failed_outer_commit_restores_every_reset_side_effect() {
21166 let path = temp_replica("outer-commit");
21167 let path_str = path.to_str().expect("path");
21168 let conn = Connection::open(path_str).expect("replica connection");
21171 let mut client = SyncClient::with_connection(
21172 "outer-commit".into(),
21173 &previous_version_schema(1),
21174 previous_version_limits(),
21175 conn,
21176 )
21177 .expect("v1 replica");
21178 assert_eq!(journal_mode(&client.conn), "delete");
21179 let commit = client
21180 .mutate(vec![Mutation::Upsert {
21181 table: "tasks".into(),
21182 values: Map::from_iter([
21183 ("id".into(), json!("queued")),
21184 ("note".into(), json!("dropped-by-v2")),
21185 ]),
21186 base_version: None,
21187 }])
21188 .expect("queued write");
21189 client.drain_change_batches();
21190 let mut dropped = previous_version_schema(2);
21192 dropped["tables"][0]["columns"] =
21193 json!([{ "name": "id", "type": "string", "nullable": false }]);
21194 let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
21197 let revision = client.local_revision();
21198 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
21199 let columns: i64 = client
21200 .conn
21201 .query_row(
21202 "SELECT count(*) FROM pragma_table_info('tasks')",
21203 [],
21204 |row| row.get(0),
21205 )
21206 .expect("column count");
21207 let rebuilds = client.overlay_rebuild_count.get();
21208 let prior_subs = client.subs.len();
21209 let prior_changes = client.change_queue.len();
21210 let prior_intents = client.sync_intent_queue.len();
21211 let prior_last_change = client.last_change.clone();
21212 let prior_overlay_dirty = client.overlay_dirty.get();
21213 let prior_sync_needed = client.sync_needed;
21214 let prior_upgrading = client.upgrading;
21215 let prior_stopped = client.stopped;
21216 let prior_schema_floor = client.schema_floor.clone();
21217 let prior_active_round = client.active_round;
21218 let reader = Connection::open(path_str).expect("reader");
21221 reader
21222 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
21223 .expect("reader snapshot");
21224 client
21225 .conn
21226 .busy_timeout(std::time::Duration::ZERO)
21227 .expect("busy timeout");
21228 let error = client
21229 .recreate_with_schema(&dropped)
21230 .expect_err("a blocked COMMIT must refuse the recreation");
21231 assert!(error.starts_with("client.storage_busy:"), "{error}");
21232 assert!(
21233 client.overlay_rebuild_count.get() > rebuilds,
21234 "the reset ran to its overlay rebuild inside the transaction"
21235 );
21236 assert!(client.conn.is_autocommit(), "the transaction is released");
21237 assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
21239 assert_eq!(client.local_revision(), revision);
21240 assert_eq!(
21241 client.query("SELECT * FROM tasks", &[]).expect("rows"),
21242 rows
21243 );
21244 assert_eq!(
21245 client
21246 .conn
21247 .query_row(
21248 "SELECT count(*) FROM pragma_table_info('tasks')",
21249 [],
21250 |row| { row.get::<_, i64>(0) }
21251 )
21252 .expect("column count"),
21253 columns,
21254 "the previous table layout survives the rollback"
21255 );
21256 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
21257 assert_eq!(client.schema.version, 1, "compiled schema");
21260 assert_eq!(client.subs.len(), prior_subs);
21261 assert_eq!(client.change_queue.len(), prior_changes);
21262 assert_eq!(client.sync_intent_queue.len(), prior_intents);
21263 assert_eq!(client.last_change, prior_last_change);
21264 assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
21265 assert_eq!(client.sync_needed, prior_sync_needed);
21266 assert_eq!(client.upgrading, prior_upgrading);
21267 assert_eq!(client.stopped, prior_stopped);
21268 assert_eq!(client.schema_floor, prior_schema_floor);
21269 assert_eq!(client.active_round, prior_active_round);
21270 assert!(
21271 client.rejections.is_empty(),
21272 "the dropped-column rejection is rolled back too"
21273 );
21274 let container = crate::previous_version::previous_version_container_path(path_str);
21277 assert!(std::path::Path::new(&container).exists());
21278 drop(reader);
21281 let snapshot = client
21282 .previous_version_snapshot(&PreviousVersionReadSpec {
21283 table: "tasks".into(),
21284 ..Default::default()
21285 })
21286 .expect("refuse stale capture");
21287 assert!(!snapshot.available);
21288 assert_eq!(snapshot.current_version, 1);
21289 assert!(!std::path::Path::new(&container).exists());
21290 client
21295 .conn
21296 .busy_timeout(std::time::Duration::from_secs(1))
21297 .expect("busy timeout");
21298 client
21299 .recreate_with_schema(&dropped)
21300 .expect("the recreation succeeds once the reader releases");
21301 assert_eq!(client.schema.version, 2);
21302 assert_eq!(
21303 client.pending_commit_ids(),
21304 vec![commit.clone()],
21305 "the reset kept the incompatible commit pending"
21306 );
21307 assert!(
21308 client.rejections.is_empty(),
21309 "no rejection exists before the send-time prepass"
21310 );
21311 client
21313 .prepare_sync_round(false)
21314 .expect("the send prepass runs during round preparation");
21315 assert!(client.pending_commit_ids().is_empty());
21316 assert!(
21317 client
21318 .rejections
21319 .iter()
21320 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
21321 "the dropped-column commit surfaced as a rejection at send time"
21322 );
21323 client.previous_version_discard();
21324 drop(client);
21325 std::fs::remove_file(path).expect("remove replica");
21326 }
21327
21328 #[test]
21329 fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
21330 let v1 = previous_version_schema(1);
21331 let mut v2 = previous_version_schema(2);
21332 v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
21334 let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
21335 let dropped = client
21336 .mutate(vec![Mutation::Upsert {
21337 table: "tasks".into(),
21338 values: Map::from_iter([
21339 ("id".into(), json!("dropped")),
21340 ("note".into(), json!("gone")),
21341 ]),
21342 base_version: None,
21343 }])
21344 .unwrap();
21345 let survivor = client
21349 .mutate(vec![Mutation::Delete {
21350 table: "tasks".into(),
21351 row_id: "gone".into(),
21352 base_version: None,
21353 }])
21354 .unwrap();
21355 client
21356 .recreate_with_schema(&v2)
21357 .expect("the reset preserves the outbox");
21358 assert_eq!(
21359 client.pending_commit_ids(),
21360 vec![dropped.clone(), survivor.clone()],
21361 "the reset preserves both commits"
21362 );
21363 assert!(client.rejections.is_empty(), "the reset classifies nothing");
21364 client.drain_change_batches();
21365 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
21366 assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
21367
21368 for fault in [
21374 "outcome-insert",
21375 "outbox-delete",
21376 "overlay-rebuild",
21377 "publish-revision",
21378 ] {
21379 client.set_sync_needed(false, false);
21380 client.drain_change_batches();
21381 let revision = client.local_revision();
21382 let trigger = match fault {
21383 "outcome-insert" => {
21384 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21385 }
21386 "outbox-delete" => {
21387 "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21388 }
21389 "publish-revision" => {
21394 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21395 }
21396 _ => {
21397 "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21398 }
21399 };
21400 client.conn.execute_batch(trigger).unwrap();
21401 let outcome = client
21402 .prepare_sync_round(false)
21403 .err()
21404 .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
21405 assert!(
21406 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
21407 "{fault}: {outcome:?}"
21408 );
21409 client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
21410 assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
21411 assert_eq!(
21412 client.pending_commit_ids(),
21413 vec![dropped.clone(), survivor.clone()],
21414 "{fault}: the outbox is restored"
21415 );
21416 assert!(
21417 client.rejections.is_empty(),
21418 "{fault}: no in-memory rejection survives"
21419 );
21420 assert!(
21421 client.commit_outcome(&dropped).unwrap().is_none(),
21422 "{fault}: the durable outcome rolled back"
21423 );
21424 assert_eq!(client.local_revision(), revision, "{fault}: revision");
21425 assert_eq!(
21426 client.query("SELECT * FROM tasks", &[]).unwrap(),
21427 rows,
21428 "{fault}: the visible projection is unchanged"
21429 );
21430 assert!(
21431 client.drain_change_batches().is_empty(),
21432 "{fault}: nothing is published"
21433 );
21434 }
21435
21436 client
21439 .conn
21440 .execute_batch(
21441 "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
21442 CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
21443 BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
21444 )
21445 .unwrap();
21446 assert!(client.drop_incompatible_outbox().is_err());
21447 assert!(!client.conn.is_autocommit());
21448 assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
21449 assert_eq!(
21450 client.pending_commit_ids(),
21451 vec![dropped.clone(), survivor.clone()]
21452 );
21453 assert!(client.commit_outcome(&dropped).unwrap().is_none());
21454 client
21455 .conn
21456 .execute_batch("DROP TRIGGER fail_drop; COMMIT")
21457 .unwrap();
21458
21459 client.set_meta(LOG_EPOCH_KEY, "epoch");
21464 client.set_sync_needed(false, false);
21465 client.drain_change_batches();
21466 let revision = client.local_revision();
21467 let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
21468 assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
21469 assert_eq!(
21470 prepared.meta.pushed_ids,
21471 vec![survivor.clone()],
21472 "the incompatible commit is classified before any operation is sent"
21473 );
21474 let rejection = client
21475 .rejections
21476 .iter()
21477 .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
21478 .expect("a sync.outbox_incompatible rejection");
21479 assert_eq!(rejection.client_commit_id, dropped);
21480 assert!(
21481 !rejection.retryable,
21482 "a schema-incompatible commit is final"
21483 );
21484 assert_eq!(
21485 client
21486 .commit_outcome(&dropped)
21487 .unwrap()
21488 .expect("a durable rejected outcome")
21489 .status,
21490 CommitOutcomeStatus::Rejected
21491 );
21492 assert_eq!(client.local_revision(), revision + 1, "one revision");
21493 let batches = client.drain_change_batches();
21494 assert_eq!(batches.len(), 1, "one change batch");
21495 assert!(
21496 batches[0].rejections_changed
21497 && batches[0].outcomes_changed
21498 && batches[0].status.is_some(),
21499 "{:?}",
21500 batches[0]
21501 );
21502 assert!(
21503 batches[0].tables.iter().any(|entry| entry.table == "tasks"),
21504 "{:?}",
21505 batches[0]
21506 );
21507 let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
21508 assert!(
21509 visible.is_empty(),
21510 "the dropped projection is undone: {visible:?}"
21511 );
21512
21513 let path = temp_replica("outbox-drop-commit");
21517 let path_str = path.to_str().expect("path");
21518 let conn = Connection::open(path_str).expect("replica connection");
21519 let mut client =
21520 SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
21521 .expect("v1 replica");
21522 let dropped = client
21523 .mutate(vec![Mutation::Upsert {
21524 table: "tasks".into(),
21525 values: Map::from_iter([
21526 ("id".into(), json!("dropped")),
21527 ("note".into(), json!("gone")),
21528 ]),
21529 base_version: None,
21530 }])
21531 .unwrap();
21532 client
21533 .recreate_with_schema(&v2)
21534 .expect("the reset preserves the outbox");
21535 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
21536 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
21537 assert_eq!(rows.len(), 1, "the optimistic row is visible");
21538 client.set_sync_needed(false, false);
21539 client.drain_change_batches();
21540 let revision = client.local_revision();
21541 let reader = Connection::open(path_str).expect("reader");
21542 reader
21543 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
21544 .expect("reader snapshot");
21545 client
21546 .conn
21547 .busy_timeout(std::time::Duration::ZERO)
21548 .expect("busy timeout");
21549 let outcome = client
21550 .prepare_sync_round(false)
21551 .err()
21552 .expect("a blocked COMMIT must fail the round");
21553 assert!(
21554 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
21555 "{outcome:?}"
21556 );
21557 assert!(client.conn.is_autocommit(), "the savepoint is released");
21558 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
21559 assert!(client.rejections.is_empty());
21560 assert!(client.commit_outcome(&dropped).unwrap().is_none());
21561 assert_eq!(client.local_revision(), revision);
21562 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
21563 assert!(client.drain_change_batches().is_empty());
21564 drop(reader);
21566 client
21567 .conn
21568 .busy_timeout(std::time::Duration::from_secs(1))
21569 .expect("busy timeout");
21570 client
21571 .prepare_sync_round(false)
21572 .expect("the drop succeeds after the reader releases");
21573 assert!(client.pending_commit_ids().is_empty());
21574 assert!(client
21575 .rejections
21576 .iter()
21577 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
21578 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
21579 drop(client);
21580 std::fs::remove_file(path).expect("remove replica");
21581 }
21582
21583 #[test]
21584 fn previous_version_read_reasons_come_from_live_client_state() {
21585 use crate::previous_version::{
21586 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
21587 };
21588 let path = std::env::temp_dir()
21589 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
21590 .to_string_lossy()
21591 .into_owned();
21592 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
21593 {
21594 let mut setup = SyncClient::open_path(
21595 "wiring".to_owned(),
21596 &previous_version_schema(1),
21597 previous_version_limits(),
21598 &path,
21599 )
21600 .expect("v1 install");
21601 setup
21602 .mutate(vec![Mutation::Upsert {
21603 table: "tasks".to_owned(),
21604 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
21605 base_version: None,
21606 }])
21607 .expect("seed a row");
21608 }
21609 let mut client = SyncClient::open_path(
21610 "wiring".to_owned(),
21611 &previous_version_schema(2),
21612 previous_version_limits(),
21613 &path,
21614 )
21615 .expect("v2 bump captures");
21616 assert!(std::path::Path::new(&container).exists());
21617 let spec = PreviousVersionReadSpec {
21618 table: "tasks".to_owned(),
21619 ..Default::default()
21620 };
21621
21622 recapture_previous_version(&client);
21624 client.lease_state = Some(LeaseState {
21625 lease_id: None,
21626 expires_at_ms: None,
21627 error_code: Some("lease.expired".to_owned()),
21628 });
21629 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21630 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
21631 assert!(!snapshot.available);
21632 assert!(!std::path::Path::new(&container).exists());
21633
21634 recapture_previous_version(&client);
21636 client.set_now_ms(1_000);
21637 client.lease_state = Some(LeaseState {
21638 lease_id: Some("lease".to_owned()),
21639 expires_at_ms: Some(1_000),
21640 error_code: None,
21641 });
21642 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21643 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
21644 assert!(!std::path::Path::new(&container).exists());
21645 client.lease_state = None;
21646
21647 recapture_previous_version(&client);
21649 client.subs.push(subscription(SubState::Revoked, 0, None));
21650 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21651 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
21652 assert!(!std::path::Path::new(&container).exists());
21653 client.subs.clear();
21654
21655 recapture_previous_version(&client);
21658 client
21659 .subs
21660 .push(subscription(SubState::Active, 0, Some("resume")));
21661 assert!(!client.previous_version_coverage_complete());
21662 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21663 assert!(snapshot.available);
21664 assert_eq!(snapshot.previous_version, Some(2));
21665 assert_eq!(snapshot.rows.len(), 1);
21666
21667 recapture_previous_version(&client);
21668 client.subs[0].bootstrap_state = None;
21669 assert!(client.previous_version_coverage_complete());
21670 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21671 assert_eq!(
21672 snapshot.reason,
21673 Some(PreviousVersionReason::CoverageComplete)
21674 );
21675 assert!(!std::path::Path::new(&container).exists());
21676 client.subs.clear();
21677
21678 assert!(!client.previous_version_coverage_complete());
21680
21681 drop(client);
21682 let _ = std::fs::remove_file(&path);
21683 }
21684
21685 #[test]
21692 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
21693 let path = std::env::temp_dir()
21694 .join(format!(
21695 "syncular-prev-lifetime-{}.db",
21696 uuid::Uuid::new_v4()
21697 ))
21698 .to_string_lossy()
21699 .into_owned();
21700 let container = format!(
21701 "{path}{}",
21702 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
21703 );
21704 let exists = || std::path::Path::new(&container).exists();
21705 {
21706 let mut setup = SyncClient::open_path(
21707 "lifetime".to_owned(),
21708 &previous_version_schema(1),
21709 previous_version_limits(),
21710 &path,
21711 )
21712 .expect("v1 install");
21713 setup
21714 .mutate(vec![Mutation::Upsert {
21715 table: "tasks".to_owned(),
21716 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
21717 base_version: None,
21718 }])
21719 .expect("seed a row");
21720 }
21721 let mut client = SyncClient::open_path(
21722 "lifetime".to_owned(),
21723 &previous_version_schema(2),
21724 previous_version_limits(),
21725 &path,
21726 )
21727 .expect("v2 bump captures");
21728 client.set_now_ms(1_000);
21731 assert!(exists());
21732
21733 client.lease_state = Some(LeaseState {
21735 lease_id: None,
21736 expires_at_ms: None,
21737 error_code: Some("lease.expired".to_owned()),
21738 });
21739 client.previous_version_lifetime_check();
21740 assert!(!exists());
21741 client.lease_state = None;
21742
21743 recapture_previous_version(&client);
21746 client.subs.push(subscription(SubState::Active, 0, None));
21747 client.previous_version_lifetime_check();
21748 assert!(!exists());
21749 client.subs.clear();
21750
21751 recapture_previous_version(&client);
21753 client.subs.push(subscription(SubState::Revoked, 0, None));
21754 client.previous_version_lifetime_check();
21755 assert!(!exists());
21756 client.subs.clear();
21757
21758 recapture_previous_version(&client);
21761 client
21762 .subs
21763 .push(subscription(SubState::Active, 0, Some("resume")));
21764 client.previous_version_lifetime_check();
21765 assert!(exists());
21766 client.subs.clear();
21767
21768 drop(client);
21769 let _ = std::fs::remove_file(&path);
21770 let _ = std::fs::remove_file(&container);
21771 let _ = std::fs::remove_file(format!("{container}-wal"));
21772 let _ = std::fs::remove_file(format!("{container}-shm"));
21773 }
21774}