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 #[test]
1443 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1444 assert_eq!(
1445 client().diagnostics_storage().blob_cache_bytes_approx,
1446 Some(0)
1447 );
1448 let client = SyncClient::new(
1449 "diagnostic-storage".to_owned(),
1450 &json!({
1451 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1452 "columns": [{"name": "id", "type": "string", "nullable": false},
1453 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1454 }),
1455 ClientLimits {
1456 blob_cache_max_bytes: Some(5),
1457 ..Default::default()
1458 },
1459 )
1460 .unwrap();
1461 let empty = client.diagnostics_storage();
1462 assert_eq!(empty.status, "healthy");
1463 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1464 assert_eq!(empty.retained_outcome_entries, Some(0));
1465 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1466 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1467 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1468 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1469 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1470 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1471 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1472 let written = client.diagnostics_storage();
1473 assert_eq!(written.status, "pressure");
1474 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1475 assert_eq!(written.retained_outcome_entries, Some(2));
1476 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1477 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1478 assert_eq!(
1479 written.pressure_reason_code.as_deref(),
1480 Some("client.blob_cache_over_limit")
1481 );
1482 client
1483 .conn
1484 .execute_batch(
1485 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1486 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1487 )
1488 .unwrap();
1489 let staged = client.diagnostics_storage();
1490 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1491 assert_eq!(staged.retained_outcome_entries, Some(0));
1492 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1493 assert_eq!(staged.status, "healthy");
1494 client.conn.execute_batch("ROLLBACK").unwrap();
1495 assert_eq!(client.diagnostics_storage(), written);
1496 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1497 let unreadable = client.diagnostics_storage();
1498 assert_eq!(unreadable.status, "unreadable");
1499 assert!(unreadable.database_bytes_approx.is_none());
1500 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1501 assert!(unreadable.retained_outcome_entries.is_none());
1502 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1503 assert!(unreadable.blob_cache_bytes_approx.is_none());
1504 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1505 let restored = client.diagnostics_storage();
1506 assert_eq!(restored.status, written.status);
1507 assert_eq!(
1508 restored.retained_outcome_bytes_approx,
1509 written.retained_outcome_bytes_approx
1510 );
1511 client.conn.set_prepared_statement_cache_capacity(0);
1512 assert_eq!(client.diagnostics_storage(), restored);
1513 }
1514
1515 struct ServerErrorTransport {
1517 code: &'static str,
1518 retryable: bool,
1519 }
1520
1521 impl Transport for ServerErrorTransport {
1522 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1523 Ok(encode_message(&Message {
1524 wire_version: WIRE_VERSION,
1525 msg_kind: MsgKind::Response,
1526 frames: vec![
1527 Frame::RespHeader {
1528 required_schema_version: None,
1529 latest_schema_version: None,
1530 log_epoch: Some("epoch-1".to_owned()),
1531 reset_required: Some(false),
1532 },
1533 Frame::Error {
1534 code: self.code.to_owned(),
1535 message: "server error".to_owned(),
1536 category: "internal".to_owned(),
1537 retryable: self.retryable,
1538 recommended_action: "retryLater".to_owned(),
1539 details: None,
1540 },
1541 ],
1542 }))
1543 }
1544
1545 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1546 self.sync(request)
1547 }
1548
1549 fn download_segment(
1550 &mut self,
1551 _request: &SegmentRequest,
1552 _on_progress: &mut dyn FnMut(u64),
1553 ) -> Result<Vec<u8>, TransportError> {
1554 Err(TransportError::new("sync.transport_failed", "offline"))
1555 }
1556
1557 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1558 Ok(())
1559 }
1560
1561 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1562 Ok(())
1563 }
1564
1565 fn realtime_close(&mut self) -> Result<(), TransportError> {
1566 Ok(())
1567 }
1568 }
1569
1570 #[test]
1571 fn retryable_server_error_schedules_a_background_retry() {
1572 let mut client = client();
1573 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1574 let mut transport = ServerErrorTransport {
1575 code: "sync.internal_error",
1576 retryable: true,
1577 };
1578 assert!(matches!(
1579 client.sync(&mut transport),
1580 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1581 ));
1582 assert!(matches!(
1583 client.drain_sync_intents().as_slice(),
1584 [SyncIntent::Background { delay_ms: 250 }]
1585 ));
1586 assert_eq!(
1587 client.progress().snapshot().unwrap().retry_delay_ms,
1588 Some(250)
1589 );
1590 let mut transport = ServerErrorTransport {
1591 code: "sync.invalid_request",
1592 retryable: false,
1593 };
1594 assert!(matches!(
1595 client.sync(&mut transport),
1596 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1597 ));
1598 assert!(client.drain_sync_intents().is_empty());
1599 let progress = client.progress().snapshot().unwrap();
1600 assert_eq!(progress.state, ProgressState::Failed);
1601 assert_eq!(progress.retry_delay_ms, None);
1602 }
1603
1604 #[test]
1605 fn sync_until_idle_reports_budget_exhaustion_or_failure() {
1606 let exhausted = SyncOutcome::BudgetExhausted(SyncReport::default()).to_json();
1609 assert_eq!(exhausted["ok"], json!(true));
1610 assert_eq!(exhausted["budgetExhausted"], json!(true));
1611 assert!(exhausted["report"].is_object());
1612
1613 let mut client = client();
1614 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1615 let mut idle_transport = HostTransport::new_from_config(&json!({})).unwrap();
1617 let zero = client.sync_until_idle(&mut idle_transport, Some(0));
1618 assert!(
1619 matches!(zero, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
1620 "{zero:?}"
1621 );
1622 let mut erroring = ServerErrorTransport {
1624 code: "sync.internal_error",
1625 retryable: false,
1626 };
1627 let failed = client.sync_until_idle(&mut erroring, Some(3));
1628 assert!(
1629 matches!(failed, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1630 "{failed:?}"
1631 );
1632 }
1633
1634 struct ScriptedSyncTransport {
1637 responses: std::collections::VecDeque<Message>,
1638 calls: usize,
1639 }
1640
1641 impl Transport for ScriptedSyncTransport {
1642 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1643 self.calls += 1;
1644 let response = self.responses.pop_front().expect("scripted response");
1645 Ok(encode_message(&response))
1646 }
1647
1648 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1649 self.sync(request)
1650 }
1651
1652 fn download_segment(
1653 &mut self,
1654 _request: &SegmentRequest,
1655 _on_progress: &mut dyn FnMut(u64),
1656 ) -> Result<Vec<u8>, TransportError> {
1657 Err(TransportError::new("sync.transport_failed", "offline"))
1658 }
1659
1660 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1661 Ok(())
1662 }
1663
1664 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1665 Ok(())
1666 }
1667
1668 fn realtime_close(&mut self) -> Result<(), TransportError> {
1669 Ok(())
1670 }
1671 }
1672
1673 #[test]
1674 fn sync_until_idle_success_then_failure_keeps_earlier_work() {
1675 let path = std::env::temp_dir().join(format!(
1676 "syncular-budget-script-{}.sqlite",
1677 uuid::Uuid::new_v4()
1678 ));
1679 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1680 {"name":"id","type":"string","nullable":false},
1681 {"name":"project_id","type":"string","nullable":false}],
1682 "scopes":[{"pattern":"project:{project_id}"}]}]});
1683 let mut client = SyncClient::open_path(
1684 "budget-script".into(),
1685 &schema,
1686 ClientLimits::default(),
1687 path.to_str().unwrap(),
1688 )
1689 .expect("open client");
1690 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1691 client
1692 .subscribe(
1693 "tasks".into(),
1694 "tasks".into(),
1695 vec![("project_id".into(), vec!["p1".into()])],
1696 None,
1697 )
1698 .unwrap();
1699 client.subs[0].cursor = 0;
1700 client.subs[0].synced_once = true;
1701 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1702 client.persist_sub(&client.subs[0]).unwrap();
1703 client.drain_change_batches();
1704
1705 let row = encode_row_json(
1706 client.schema.table("tasks").unwrap(),
1707 "kept",
1708 &Map::from_iter([
1709 ("id".into(), json!("kept")),
1710 ("project_id".into(), json!("p1")),
1711 ]),
1712 &client.encryption,
1713 )
1714 .unwrap();
1715 let success = Message {
1716 wire_version: WIRE_VERSION,
1717 msg_kind: MsgKind::Response,
1718 frames: vec![
1719 Frame::RespHeader {
1720 required_schema_version: None,
1721 latest_schema_version: None,
1722 log_epoch: Some("epoch-1".into()),
1723 reset_required: Some(false),
1724 },
1725 Frame::SubStart {
1726 id: "tasks".into(),
1727 status: SubStatus::Active,
1728 reason_code: String::new(),
1729 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1730 bootstrap: false,
1731 },
1732 Frame::Commit {
1733 commit_seq: 1,
1734 created_at_ms: 1,
1735 actor_id: "writer".into(),
1736 tables: vec!["tasks".into()],
1737 changes: vec![ssp2::model::Change {
1738 table_index: 0,
1739 row_id: "kept".into(),
1740 op: Op::Upsert,
1741 row_version: Some(1),
1742 scopes: vec![("project_id".into(), "p1".into())],
1743 row: Some(row),
1744 }],
1745 },
1746 Frame::SubEnd {
1747 next_cursor: 1,
1748 bootstrap_state: None,
1749 },
1750 ],
1751 };
1752 let failure = Message {
1753 wire_version: WIRE_VERSION,
1754 msg_kind: MsgKind::Response,
1755 frames: vec![
1756 Frame::RespHeader {
1757 required_schema_version: None,
1758 latest_schema_version: None,
1759 log_epoch: Some("epoch-1".into()),
1760 reset_required: Some(false),
1761 },
1762 Frame::Error {
1763 code: "sync.internal_error".into(),
1764 message: "second round failed".into(),
1765 category: "internal".into(),
1766 retryable: false,
1767 recommended_action: "retryLater".into(),
1768 details: None,
1769 },
1770 ],
1771 };
1772 let mut transport = ScriptedSyncTransport {
1773 responses: std::collections::VecDeque::from([success, failure]),
1774 calls: 0,
1775 };
1776 let outcome = client.sync_until_idle(&mut transport, Some(2));
1777 assert!(
1778 matches!(outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1779 "{outcome:?}"
1780 );
1781 assert_eq!(
1782 transport.calls, 2,
1783 "one successful round then the failing round"
1784 );
1785 assert_eq!(
1786 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
1787 json!("kept"),
1788 "the earlier round's durable work survives the later failure"
1789 );
1790 drop(client);
1791 let _ = std::fs::remove_file(&path);
1792 }
1793
1794 #[test]
1795 fn sync_until_idle_stops_on_a_schema_floor() {
1796 let mut client = client();
1797 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1798 let floor = Message {
1799 wire_version: WIRE_VERSION,
1800 msg_kind: MsgKind::Response,
1801 frames: vec![Frame::RespHeader {
1802 required_schema_version: Some(2),
1803 latest_schema_version: Some(2),
1804 log_epoch: Some("epoch-1".into()),
1805 reset_required: Some(false),
1806 }],
1807 };
1808 let mut transport = ScriptedSyncTransport {
1809 responses: std::collections::VecDeque::from([floor.clone(), floor]),
1810 calls: 0,
1811 };
1812 let outcome = client.sync_until_idle(&mut transport, Some(3));
1813 match outcome {
1814 SyncOutcome::Ok(report) => assert_eq!(
1815 report
1816 .schema_floor
1817 .as_ref()
1818 .and_then(|floor| floor.required_schema_version),
1819 Some(2),
1820 "the stop retains the latest schema floor"
1821 ),
1822 other => panic!("expected a schema-floor stop, got {other:?}"),
1823 }
1824 assert_eq!(
1825 transport.calls, 1,
1826 "a schema floor stops the budget loop after one round"
1827 );
1828 }
1829
1830 #[test]
1831 fn sync_report_merge_schema_floor_is_latest_state() {
1832 let mut aggregate = SyncReport {
1833 schema_floor: Some(SchemaFloor {
1834 required_schema_version: Some(2),
1835 latest_schema_version: Some(2),
1836 }),
1837 ..SyncReport::default()
1838 };
1839 aggregate.merge(&SyncReport::default());
1840 assert!(
1841 aggregate.schema_floor.is_none(),
1842 "a later round without a floor clears it"
1843 );
1844 }
1845
1846 #[test]
1847 fn push_capacity_limits_are_hard_and_keep_intent() {
1848 let mut c = client();
1850 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1851 c.limits.max_push_operations_per_request = Some(1);
1852 let id = c
1853 .mutate(vec![
1854 Mutation::Upsert {
1855 table: "tasks".into(),
1856 values: Map::from_iter([
1857 ("id".into(), json!("a")),
1858 ("project_id".into(), json!("p1")),
1859 ]),
1860 base_version: None,
1861 },
1862 Mutation::Upsert {
1863 table: "tasks".into(),
1864 values: Map::from_iter([
1865 ("id".into(), json!("b")),
1866 ("project_id".into(), json!("p1")),
1867 ]),
1868 base_version: None,
1869 },
1870 ])
1871 .unwrap();
1872 let error = c.build_request(false).unwrap_err();
1873 let SyncOutcome::Failed {
1874 error_code,
1875 details,
1876 ..
1877 } = *error
1878 else {
1879 panic!("expected a capacity failure");
1880 };
1881 assert_eq!(error_code, "client.push_request_too_large");
1882 let details = details.expect("capacity details");
1883 assert_eq!(details["kind"], json!("operations"));
1884 assert_eq!(details["limit"], json!(1));
1885 assert_eq!(details["size"], json!(2));
1886 assert_eq!(details["clientCommitId"], json!(id));
1887 assert_eq!(c.pending_commit_ids(), vec![id]);
1888
1889 let mut c = client();
1891 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1892 c.limits.max_push_commits_per_request = Some(1);
1893 let first = c
1894 .mutate(vec![Mutation::Upsert {
1895 table: "tasks".into(),
1896 values: Map::from_iter([
1897 ("id".into(), json!("one")),
1898 ("project_id".into(), json!("p1")),
1899 ]),
1900 base_version: None,
1901 }])
1902 .unwrap();
1903 let second = c
1904 .mutate(vec![Mutation::Upsert {
1905 table: "tasks".into(),
1906 values: Map::from_iter([
1907 ("id".into(), json!("two")),
1908 ("project_id".into(), json!("p1")),
1909 ]),
1910 base_version: None,
1911 }])
1912 .unwrap();
1913 let (_, meta) = c.build_request(false).unwrap();
1914 assert_eq!(meta.pushed_ids, vec![first.clone()]);
1915 assert_eq!(meta.deferred_commits, 1);
1916 assert_eq!(c.pending_commit_ids(), vec![first, second]);
1917 }
1918
1919 #[test]
1920 fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
1921 let mut c = client();
1924 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1925 let first = c
1926 .mutate(vec![Mutation::Upsert {
1927 table: "tasks".into(),
1928 values: Map::from_iter([
1929 ("id".into(), json!("one")),
1930 ("project_id".into(), json!("p1")),
1931 ]),
1932 base_version: None,
1933 }])
1934 .unwrap();
1935 let second = c
1936 .mutate(vec![Mutation::Upsert {
1937 table: "tasks".into(),
1938 values: Map::from_iter([
1939 ("id".into(), json!("two")),
1940 ("project_id".into(), json!("p1")),
1941 ]),
1942 base_version: None,
1943 }])
1944 .unwrap();
1945 let (message, meta) = c.build_request(false).unwrap();
1946 assert_eq!(meta.pushed_ids.len(), 2);
1947 let full = encode_message(&message).len();
1948 c.limits.max_push_request_bytes = Some(full - 1);
1949 let (_, meta) = c.build_request(false).unwrap();
1950 assert_eq!(meta.pushed_ids, vec![first.clone()]);
1951 assert_eq!(meta.deferred_commits, 1);
1952 assert_eq!(c.pending_commit_ids(), vec![first, second]);
1953
1954 let mut c = client();
1957 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1958 let huge = c
1959 .mutate(vec![Mutation::Upsert {
1960 table: "tasks".into(),
1961 values: Map::from_iter([
1962 ("id".into(), json!("x".repeat(20_000))),
1963 ("project_id".into(), json!("p1")),
1964 ]),
1965 base_version: None,
1966 }])
1967 .unwrap();
1968 let (message, meta) = c.build_request(false).unwrap();
1969 assert_eq!(meta.pushed_ids, vec![huge.clone()]);
1970 let with_huge = encode_message(&message).len();
1971 c.limits.max_push_request_bytes = Some(with_huge - 1);
1972 let error = c.build_request(false).unwrap_err();
1973 let SyncOutcome::Failed {
1974 error_code,
1975 details,
1976 ..
1977 } = *error
1978 else {
1979 panic!("expected a capacity failure");
1980 };
1981 assert_eq!(error_code, "client.push_request_too_large");
1982 let details = details.expect("capacity details");
1983 assert_eq!(details["kind"], json!("bytes"));
1984 assert_eq!(details["limit"], json!((with_huge - 1) as u64));
1985 assert_eq!(details["size"], json!(with_huge as u64));
1986 assert_eq!(details["clientCommitId"], json!(huge));
1987 assert_eq!(c.pending_commit_ids(), vec![huge]);
1988
1989 let mut c = client();
1991 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1992 c.limits.max_push_request_bytes = Some(1);
1993 let error = c.build_request(false).unwrap_err();
1994 let SyncOutcome::Failed { details, .. } = *error else {
1995 panic!("expected a capacity failure");
1996 };
1997 let details = details.expect("capacity details");
1998 assert_eq!(details["kind"], json!("bytes"));
1999 assert!(details.get("clientCommitId").is_none(), "{details}");
2000 }
2001
2002 #[test]
2003 fn push_limits_validate_at_construction() {
2004 for limits in [
2005 ClientLimits {
2006 max_push_commits_per_request: Some(0),
2007 ..Default::default()
2008 },
2009 ClientLimits {
2010 max_push_operations_per_request: Some(0),
2011 ..Default::default()
2012 },
2013 ClientLimits {
2014 max_push_request_bytes: Some(0),
2015 ..Default::default()
2016 },
2017 ClientLimits {
2018 max_push_request_bytes: Some(u32::MAX as usize + 1),
2019 ..Default::default()
2020 },
2021 ] {
2022 let schema = json!({"version":1,"tables":[]});
2023 let error = SyncClient::with_connection(
2024 "bad-limits".into(),
2025 &schema,
2026 limits,
2027 Connection::open_in_memory().unwrap(),
2028 )
2029 .err();
2030 assert_eq!(
2031 error.as_deref(),
2032 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2033 );
2034 }
2035 }
2036
2037 #[test]
2038 fn open_path_rejects_invalid_push_limits_before_storage() {
2039 for identity in [false, true] {
2040 let path = std::env::temp_dir().join(format!(
2041 "syncular-bad-limits-{}.sqlite",
2042 uuid::Uuid::new_v4()
2043 ));
2044 let schema = json!({"version":1,"tables":[]});
2045 let limits = ClientLimits {
2046 max_push_request_bytes: Some(0),
2047 ..Default::default()
2048 };
2049 let error = if identity {
2050 SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
2051 } else {
2052 SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
2053 }
2054 .err();
2055 assert_eq!(
2056 error.as_deref(),
2057 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2058 );
2059 assert!(
2060 !path.exists(),
2061 "an invalid limit is rejected before the database file is created"
2062 );
2063 }
2064 }
2065
2066 #[test]
2067 fn failed_progress_retry_delay_doubles_to_the_cap() {
2068 let mut client = client();
2069 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2070 let mut transport = ServerErrorTransport {
2071 code: "sync.internal_error",
2072 retryable: true,
2073 };
2074 let delays: Vec<Option<u64>> = (0..9)
2075 .map(|_| {
2076 client.sync(&mut transport);
2077 client.progress().snapshot().unwrap().retry_delay_ms
2078 })
2079 .collect();
2080 assert_eq!(
2081 delays,
2082 [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
2083 );
2084 }
2085
2086 #[test]
2087 fn background_retry_deadlines_back_off_and_reset() {
2088 let mut client = client();
2089 client.schedule_background_retry();
2090 assert!(matches!(
2091 client.drain_sync_intents().as_slice(),
2092 [SyncIntent::Background { delay_ms: 250 }]
2093 ));
2094 client.schedule_background_retry();
2095 assert!(matches!(
2096 client.drain_sync_intents().as_slice(),
2097 [SyncIntent::Background { delay_ms: 500 }]
2098 ));
2099 client.reset_background_retry();
2100 client.schedule_background_retry();
2101 assert!(matches!(
2102 client.drain_sync_intents().as_slice(),
2103 [SyncIntent::Background { delay_ms: 250 }]
2104 ));
2105 }
2106
2107 #[test]
2108 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
2109 let mut client = client();
2110 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2111 client
2112 .subscribe(
2113 "epoch-tasks".to_owned(),
2114 "tasks".to_owned(),
2115 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2116 None,
2117 )
2118 .expect("subscribe");
2119 client.drain_sync_intents();
2120
2121 client
2122 .run_log_epoch_reset("epoch-2")
2123 .expect("reset partition log epoch");
2124
2125 assert!(matches!(
2126 client.drain_sync_intents().as_slice(),
2127 [SyncIntent::Interactive]
2128 ));
2129 }
2130
2131 #[derive(Default)]
2132 struct CountingRealtimeTransport {
2133 connects: usize,
2134 closes: usize,
2135 messages: Vec<String>,
2136 blob_uploads: Vec<(String, Vec<u8>)>,
2137 blob_download: Option<Vec<u8>>,
2138 sync_calls: usize,
2139 realtime_sync_calls: usize,
2140 refuse_connect: bool,
2141 }
2142
2143 impl Transport for CountingRealtimeTransport {
2144 fn blob_upload(
2145 &mut self,
2146 blob_id: &str,
2147 bytes: &[u8],
2148 _media_type: Option<&str>,
2149 ) -> Result<(), TransportError> {
2150 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
2151 Ok(())
2152 }
2153
2154 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
2155 self.blob_download
2156 .clone()
2157 .map(BlobDownload::Bytes)
2158 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
2159 }
2160
2161 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2162 self.sync_calls += 1;
2163 Err(TransportError::new("sync.transport_failed", "offline"))
2164 }
2165
2166 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2167 self.realtime_sync_calls += 1;
2168 Err(TransportError::new("sync.transport_failed", "offline"))
2169 }
2170
2171 fn download_segment(
2172 &mut self,
2173 _request: &SegmentRequest,
2174 _on_progress: &mut dyn FnMut(u64),
2175 ) -> Result<Vec<u8>, TransportError> {
2176 Err(TransportError::new("sync.transport_failed", "offline"))
2177 }
2178
2179 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2180 self.connects += 1;
2181 if self.refuse_connect {
2182 return Err(TransportError::new("transport.failed", "refused"));
2183 }
2184 Ok(())
2185 }
2186
2187 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
2188 self.messages.push(text.to_owned());
2189 Ok(())
2190 }
2191
2192 fn realtime_close(&mut self) -> Result<(), TransportError> {
2193 self.closes += 1;
2194 Ok(())
2195 }
2196 }
2197
2198 #[test]
2199 fn blob_results_own_downloaded_bytes() {
2200 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
2201 {"name":"id","type":"string","nullable":false},
2202 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
2203 let bytes = b"owned typed result".to_vec();
2204 let blob_id = blob_id_for(&bytes);
2205 let mut transport = CountingRealtimeTransport {
2206 blob_download: Some(bytes.clone()),
2207 ..Default::default()
2208 };
2209 let result = {
2210 let mut client =
2211 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
2212 .unwrap();
2213 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
2214 assert_eq!(result.blob_id, blob_id);
2215 assert_eq!(result.byte_length, bytes.len() as i64);
2216 assert_eq!(result.bytes, bytes);
2217 assert_eq!(result.media_type, None);
2218 assert_eq!(
2219 client
2220 .conn
2221 .query_row(
2222 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
2223 rusqlite::params![blob_id],
2224 |row| row.get::<_, i64>(0),
2225 )
2226 .unwrap(),
2227 0
2228 );
2229 result
2230 };
2231 assert_eq!(result.bytes, bytes);
2232
2233 let mut client =
2234 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
2235 assert_eq!(
2236 client
2237 .fetch_blob_bytes(&mut transport, "not json")
2238 .unwrap_err(),
2239 (
2240 "client.failed".to_owned(),
2241 "blob ref is not JSON".to_owned()
2242 )
2243 );
2244 }
2245
2246 #[test]
2247 fn visible_blob_references_survive_cache_trimming() {
2248 let schema = json!({"version":1,"tables":[
2249 {"name":"attachments","primaryKey":"id","columns":[
2250 {"name":"id","type":"string","nullable":false},
2251 {"name":"body","type":"blob_ref","nullable":true},
2252 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
2253 {"name":"avatars","primaryKey":"id","columns":[
2254 {"name":"id","type":"string","nullable":false},
2255 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
2256 ]});
2257 let bytes = b"referenced above cap".to_vec();
2258 let blob_id = blob_id_for(&bytes);
2259 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
2260 let mut client = SyncClient::new(
2261 "visible-blob-refs".to_owned(),
2262 &schema,
2263 ClientLimits {
2264 blob_cache_max_bytes: Some(1),
2265 ..Default::default()
2266 },
2267 )
2268 .unwrap();
2269 client.conn.execute(
2270 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
2271 rusqlite::params![reference, reference, reference, reference],
2272 ).unwrap();
2273 client
2274 .conn
2275 .execute(
2276 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
2277 rusqlite::params![reference],
2278 )
2279 .unwrap();
2280 client.conn.execute(
2281 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
2282 [],
2283 ).unwrap();
2284 let mut transport = CountingRealtimeTransport {
2285 blob_download: Some(bytes.clone()),
2286 ..Default::default()
2287 };
2288
2289 assert_eq!(
2290 client
2291 .fetch_blob_bytes(&mut transport, &blob_id)
2292 .unwrap()
2293 .bytes,
2294 bytes
2295 );
2296 let rows = client
2297 .conn
2298 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
2299 .unwrap()
2300 .query_map([], |row| row.get::<_, String>(0))
2301 .unwrap()
2302 .collect::<Result<Vec<_>, _>>()
2303 .unwrap();
2304 assert_eq!(rows, vec![blob_id]);
2305 }
2306
2307 #[test]
2308 fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
2309 let mut transport = CountingRealtimeTransport::default();
2310 let path = std::env::temp_dir().join(format!(
2311 "syncular-transport-gate-{}.db",
2312 uuid::Uuid::new_v4()
2313 ));
2314 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2315 {"name":"id","type":"string","nullable":false},
2316 {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
2317 let mut client = SyncClient::open_path(
2318 "gate".into(),
2319 &schema,
2320 ClientLimits::default(),
2321 path.to_str().unwrap(),
2322 )
2323 .unwrap();
2324 client.connect_realtime(&mut transport).unwrap();
2325 client.set_transport_enabled(&mut transport, false);
2326 assert_eq!(transport.closes, 1);
2327 client.begin_security_preflight();
2328 client.activate_security(Default::default()).unwrap();
2329 client
2330 .subscribe(
2331 "tasks".into(),
2332 "tasks".into(),
2333 vec![("project_id".into(), vec!["p1".into()])],
2334 None,
2335 )
2336 .unwrap();
2337 for id in ["one", "two"] {
2338 client
2339 .mutate(vec![Mutation::Upsert {
2340 table: "tasks".into(),
2341 values: Map::from_iter([
2342 ("id".into(), json!(id)),
2343 ("project_id".into(), json!("p1")),
2344 ]),
2345 base_version: None,
2346 }])
2347 .unwrap();
2348 }
2349 assert_eq!(
2350 client
2351 .query("SELECT id FROM tasks ORDER BY id", &[])
2352 .unwrap()
2353 .len(),
2354 2
2355 );
2356 assert!(
2357 matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
2358 );
2359 assert!(client
2360 .connect_realtime(&mut transport)
2361 .unwrap_err()
2362 .starts_with("sync.offline:"));
2363 assert!(client
2364 .set_presence(&mut transport, "project:p1", None)
2365 .unwrap_err()
2366 .starts_with("sync.offline:"));
2367 assert_eq!(transport.sync_calls, 0);
2368 assert_eq!(transport.realtime_sync_calls, 0);
2369 assert_eq!(transport.connects, 1);
2370 assert!(transport.messages.is_empty());
2371 assert!(matches!(
2372 client.drain_sync_intents().as_slice(),
2373 [SyncIntent::None]
2374 ));
2375 let pending = client.pending_commit_ids();
2376 drop(client);
2377 let mut reopened = SyncClient::open_path(
2378 "gate".into(),
2379 &schema,
2380 ClientLimits::default(),
2381 path.to_str().unwrap(),
2382 )
2383 .unwrap();
2384 assert!(reopened.transport_enabled());
2385 reopened.set_transport_enabled(&mut transport, false);
2386 assert_eq!(reopened.pending_commit_ids(), pending);
2387 assert_eq!(
2388 reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
2389 2
2390 );
2391 reopened.set_transport_enabled(&mut transport, true);
2392 assert!(matches!(
2393 reopened.drain_sync_intents().as_slice(),
2394 [SyncIntent::Interactive]
2395 ));
2396 reopened.set_transport_enabled(&mut transport, true);
2397 assert!(reopened.drain_sync_intents().is_empty());
2398 drop(reopened);
2399 std::fs::remove_file(path).unwrap();
2400 }
2401
2402 #[test]
2403 fn realtime_connection_ownership_is_idempotent() {
2404 let mut client = client();
2405 let mut transport = CountingRealtimeTransport {
2406 connects: 0,
2407 closes: 0,
2408 ..Default::default()
2409 };
2410 client
2411 .connect_realtime(&mut transport)
2412 .expect("first connect");
2413 client
2414 .connect_realtime(&mut transport)
2415 .expect("idempotent connect");
2416 assert_eq!(transport.connects, 1);
2417 client.disconnect_realtime(&mut transport);
2418 client.disconnect_realtime(&mut transport);
2419 assert_eq!(transport.closes, 1);
2420 client
2421 .connect_realtime(&mut transport)
2422 .expect("deliberate reconnect");
2423 assert_eq!(transport.connects, 2);
2424 }
2425
2426 #[test]
2427 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
2428 let mut client = client();
2429 client
2430 .set_realtime_policy(RealtimePolicy::Required)
2431 .expect("set policy");
2432 let mut transport = CountingRealtimeTransport::default();
2433 match client.sync(&mut transport) {
2434 SyncOutcome::RealtimeUnavailable {
2435 state,
2436 reason_code,
2437 retry_delay_ms,
2438 } => {
2439 assert_eq!(state, RealtimeState::Disconnected);
2440 assert_eq!(reason_code, None);
2441 assert_eq!(retry_delay_ms, 250);
2442 }
2443 other => panic!("expected RealtimeUnavailable, got {other:?}"),
2444 }
2445 assert_eq!(transport.sync_calls, 0);
2446 assert!(matches!(
2447 client.drain_sync_intents().as_slice(),
2448 [SyncIntent::Background { delay_ms: 250 }]
2449 ));
2450 let snapshot = client
2451 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2452 .expect("diagnostics");
2453 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
2454 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
2455 assert_eq!(snapshot.host.realtime_reason_code, None);
2456 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
2457 assert_eq!(
2458 snapshot
2459 .last_round
2460 .as_ref()
2461 .and_then(|round| round.error_code.clone()),
2462 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
2463 );
2464 }
2465
2466 #[test]
2467 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
2468 let mut client = client();
2469 client
2470 .set_realtime_policy(RealtimePolicy::Required)
2471 .expect("set policy");
2472 let mut transport = CountingRealtimeTransport {
2473 refuse_connect: true,
2474 ..Default::default()
2475 };
2476 assert!(client.connect_realtime(&mut transport).is_err());
2477 assert_eq!(client.realtime_state(), RealtimeState::Refused);
2478 let snapshot = client
2479 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2480 .expect("diagnostics");
2481 assert_eq!(
2482 snapshot.host.realtime_reason_code.as_deref(),
2483 Some("transport.failed")
2484 );
2485 match client.sync(&mut transport) {
2486 SyncOutcome::RealtimeUnavailable {
2487 state, reason_code, ..
2488 } => {
2489 assert_eq!(state, RealtimeState::Refused);
2490 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
2491 }
2492 other => panic!("expected RealtimeUnavailable, got {other:?}"),
2493 }
2494 assert_eq!(transport.sync_calls, 0);
2495 assert_eq!(transport.realtime_sync_calls, 0);
2496 }
2497
2498 #[test]
2499 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
2500 let mut client = client();
2501 client
2502 .set_realtime_policy(RealtimePolicy::Required)
2503 .expect("set policy");
2504 let mut transport = CountingRealtimeTransport::default();
2505 client.connect_realtime(&mut transport).expect("connect");
2506 assert_eq!(client.realtime_state(), RealtimeState::Connected);
2507 assert!(
2508 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
2509 "a policy change cannot silently re-route a connected socket"
2510 );
2511 assert!(matches!(
2512 client.sync(&mut transport),
2513 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
2514 ));
2515 assert_eq!(client.realtime_state(), RealtimeState::Lost);
2516 assert_eq!(transport.realtime_sync_calls, 1);
2517 assert_eq!(transport.sync_calls, 0);
2518 let snapshot = client
2519 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2520 .expect("diagnostics");
2521 assert_eq!(
2522 snapshot.host.realtime_reason_code.as_deref(),
2523 Some("sync.transport_failed")
2524 );
2525 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
2526 match client.sync(&mut transport) {
2527 SyncOutcome::RealtimeUnavailable {
2528 state, reason_code, ..
2529 } => {
2530 assert_eq!(state, RealtimeState::Lost);
2531 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
2532 }
2533 other => panic!("expected RealtimeUnavailable, got {other:?}"),
2534 }
2535 assert_eq!(transport.sync_calls, 0);
2536 client.disconnect_realtime(&mut transport);
2537 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
2538 assert_eq!(transport.closes, 1);
2539 client.connect_realtime(&mut transport).expect("reconnect");
2540 assert_eq!(client.realtime_state(), RealtimeState::Connected);
2541 assert_eq!(transport.connects, 2);
2542 }
2543
2544 #[test]
2545 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
2546 let mut client = client();
2547 client
2548 .set_realtime_policy(RealtimePolicy::Off)
2549 .expect("set policy");
2550 let mut transport = CountingRealtimeTransport::default();
2551 assert!(client.connect_realtime(&mut transport).is_err());
2552 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
2553 assert!(matches!(
2554 client.sync(&mut transport),
2555 SyncOutcome::Failed { .. }
2556 ));
2557 assert_eq!(transport.sync_calls, 1);
2558 assert_eq!(transport.realtime_sync_calls, 0);
2559 let snapshot = client
2560 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2561 .expect("diagnostics");
2562 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
2563 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
2564 }
2565
2566 #[test]
2567 fn optional_policy_keeps_the_http_round() {
2568 let mut client = client();
2569 let mut transport = CountingRealtimeTransport::default();
2570 assert!(matches!(
2571 client.sync(&mut transport),
2572 SyncOutcome::Failed { .. }
2573 ));
2574 assert_eq!(transport.sync_calls, 1);
2575 assert_eq!(transport.realtime_sync_calls, 0);
2576 let snapshot = client
2577 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2578 .expect("diagnostics");
2579 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
2580 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
2581 }
2582
2583 #[test]
2584 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
2585 let mut client = client();
2586 client
2587 .subscribe(
2588 "repair-tasks".to_owned(),
2589 "tasks".to_owned(),
2590 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2591 None,
2592 )
2593 .expect("subscribe");
2594 {
2595 let sub = client
2596 .subs
2597 .iter_mut()
2598 .find(|sub| sub.id == "repair-tasks")
2599 .expect("subscription");
2600 sub.cursor = 42;
2601 sub.synced_once = true;
2602 let persisted = sub.clone();
2603 client.persist_sub(&persisted).unwrap();
2604 }
2605 for table in ["_syncular_base_tasks", "tasks"] {
2606 client
2607 .conn
2608 .execute(
2609 &format!(
2610 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
2611 ),
2612 rusqlite::params!["server-row", "p1"],
2613 )
2614 .expect("seed server row");
2615 }
2616 let pending = client
2617 .mutate(vec![Mutation::Upsert {
2618 table: "tasks".to_owned(),
2619 values: Map::from_iter([
2620 ("id".to_owned(), Value::from("offline-row")),
2621 ("project_id".to_owned(), Value::from("p1")),
2622 ]),
2623 base_version: None,
2624 }])
2625 .expect("queue offline work");
2626 client.drain_change_batches();
2627 client.drain_sync_intents();
2628
2629 assert_eq!(
2630 client
2631 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2632 rebootstrap_id: "support-case-001".to_owned(),
2633 })
2634 .expect("rebootstrap"),
2635 LocalDataRebootstrapResult {
2636 already_applied: false,
2637 retained_commits: 1,
2638 reset_subscriptions: 1,
2639 }
2640 );
2641 let visible_ids = client
2642 .conn
2643 .prepare("SELECT id FROM tasks ORDER BY id")
2644 .expect("prepare visible ids")
2645 .query_map([], |row| row.get::<_, String>(0))
2646 .expect("query visible ids")
2647 .collect::<Result<Vec<_>, _>>()
2648 .expect("collect visible ids");
2649 assert_eq!(visible_ids, vec!["offline-row"]);
2650 assert_eq!(client.pending_commit_ids(), vec![pending]);
2651 assert_eq!(
2652 client
2653 .subscription_state("repair-tasks")
2654 .expect("subscription")
2655 .cursor,
2656 -1
2657 );
2658 assert!(client.upgrading());
2659 assert!(client.sync_needed());
2660 assert!(matches!(
2661 client.drain_sync_intents().as_slice(),
2662 [SyncIntent::Interactive]
2663 ));
2664 assert_eq!(client.drain_change_batches().len(), 1);
2665
2666 assert_eq!(
2667 client
2668 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2669 rebootstrap_id: "support-case-001".to_owned(),
2670 })
2671 .expect("idempotent retry"),
2672 LocalDataRebootstrapResult {
2673 already_applied: true,
2674 retained_commits: 1,
2675 reset_subscriptions: 1,
2676 }
2677 );
2678 assert!(client.drain_change_batches().is_empty());
2679 }
2680
2681 #[test]
2682 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
2683 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
2684 assert_eq!(
2685 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
2686 (3, 4)
2687 );
2688 assert_eq!(
2689 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
2690 (0, 0)
2691 );
2692 for malformed in [
2693 "",
2694 "{}",
2695 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
2696 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
2697 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
2698 ] {
2699 assert_eq!(
2700 decode_local_rebootstrap_receipt(malformed)
2701 .expect_err("malformed receipt must fail"),
2702 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
2703 );
2704 }
2705 }
2706
2707 #[test]
2708 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
2709 let path = std::env::temp_dir().join(format!(
2710 "syncular-rebootstrap-receipt-{}.db",
2711 uuid::Uuid::new_v4()
2712 ));
2713 let schema = json!({
2714 "version": 1,
2715 "tables": [{
2716 "name": "tasks",
2717 "primaryKey": "id",
2718 "columns": [
2719 { "name": "id", "type": "string", "nullable": false },
2720 { "name": "project_id", "type": "string", "nullable": false }
2721 ],
2722 "scopes": [{ "pattern": "project:{project_id}" }]
2723 }]
2724 });
2725 let path_string = path.to_str().expect("UTF-8 temp path");
2726
2727 {
2728 let mut first = SyncClient::open_path(
2729 "repair-restart-client".to_owned(),
2730 &schema,
2731 ClientLimits::default(),
2732 path_string,
2733 )
2734 .expect("first open");
2735 first
2736 .subscribe(
2737 "repair-tasks".to_owned(),
2738 "tasks".to_owned(),
2739 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2740 None,
2741 )
2742 .expect("subscribe");
2743 first
2744 .mutate(vec![Mutation::Upsert {
2745 table: "tasks".to_owned(),
2746 values: Map::from_iter([
2747 ("id".to_owned(), Value::from("offline-row")),
2748 ("project_id".to_owned(), Value::from("p1")),
2749 ]),
2750 base_version: None,
2751 }])
2752 .expect("queue offline work");
2753 assert_eq!(
2754 first
2755 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2756 rebootstrap_id: "restart-receipt".to_owned(),
2757 })
2758 .expect("first rebootstrap"),
2759 LocalDataRebootstrapResult {
2760 already_applied: false,
2761 retained_commits: 1,
2762 reset_subscriptions: 1,
2763 }
2764 );
2765 }
2766
2767 let mut reopened = SyncClient::open_path(
2768 "repair-restart-client".to_owned(),
2769 &schema,
2770 ClientLimits::default(),
2771 path_string,
2772 )
2773 .expect("reopen");
2774 reopened.drain_change_batches();
2775 reopened.drain_sync_intents();
2776 assert_eq!(
2777 reopened
2778 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2779 rebootstrap_id: "restart-receipt".to_owned(),
2780 })
2781 .expect("receipt replay"),
2782 LocalDataRebootstrapResult {
2783 already_applied: true,
2784 retained_commits: 1,
2785 reset_subscriptions: 1,
2786 }
2787 );
2788 assert!(reopened.drain_change_batches().is_empty());
2789 assert!(reopened.drain_sync_intents().is_empty());
2790 drop(reopened);
2791 std::fs::remove_file(path).expect("remove temp database");
2792 }
2793
2794 #[test]
2795 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
2796 let mut malformed = client();
2797 malformed
2798 .subscribe(
2799 "repair-tasks".to_owned(),
2800 "tasks".to_owned(),
2801 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2802 None,
2803 )
2804 .expect("subscribe");
2805 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
2806 malformed.drain_change_batches();
2807 malformed.drain_sync_intents();
2808 let malformed_error = malformed
2809 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2810 rebootstrap_id: "malformed".to_owned(),
2811 })
2812 .expect_err("malformed receipt must fail closed");
2813 assert_eq!(
2814 malformed_error,
2815 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
2816 );
2817 assert!(!malformed.upgrading());
2818 assert_eq!(
2819 malformed
2820 .subscription_state("repair-tasks")
2821 .expect("unchanged subscription")
2822 .cursor,
2823 -1
2824 );
2825 assert!(malformed.drain_change_batches().is_empty());
2826 assert!(malformed.drain_sync_intents().is_empty());
2827
2828 let mut unreadable = client();
2829 unreadable
2830 .conn
2831 .execute(
2832 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
2833 rusqlite::params!["server-row", "p1"],
2834 )
2835 .expect("seed visible row");
2836 unreadable
2837 .conn
2838 .execute("DROP TABLE _syncular_meta", [])
2839 .expect("break marker storage");
2840 let unreadable_error = unreadable
2841 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2842 rebootstrap_id: "unreadable".to_owned(),
2843 })
2844 .expect_err("unreadable marker storage must fail closed");
2845 assert_eq!(
2846 unreadable_error,
2847 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
2848 );
2849 let visible_rows = unreadable
2850 .conn
2851 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
2852 .expect("visible projection remains");
2853 assert_eq!(visible_rows, 1);
2854 assert!(!unreadable.upgrading());
2855 assert!(unreadable.drain_change_batches().is_empty());
2856 assert!(unreadable.drain_sync_intents().is_empty());
2857 }
2858
2859 #[test]
2860 fn local_rebootstrap_cannot_bypass_schema_floor() {
2861 let mut client = client();
2862 client.set_schema_floor(Some(SchemaFloor {
2863 required_schema_version: Some(2),
2864 latest_schema_version: Some(2),
2865 }));
2866 let error = client
2867 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2868 rebootstrap_id: "blocked-floor".to_owned(),
2869 })
2870 .expect_err("schema floor must block repair");
2871 assert!(error.contains("cannot bypass an active schema-floor stop"));
2872 }
2873
2874 #[test]
2875 fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
2876 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2877 "columns":[{"name":"id","type":"string","nullable":false},
2878 {"name":"project_id","type":"string","nullable":false},
2879 {"name":"starts_at_ms","type":"integer","nullable":false},
2880 {"name":"version","type":"integer","nullable":false}],
2881 "scopes":[{"pattern":"project:{project_id}"}]}]});
2882 let mut client =
2883 SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
2884 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2885 client
2886 .write_base_row(
2887 "tasks",
2888 &vec![
2889 Some(ColumnValue::String("t1".into())),
2890 Some(ColumnValue::String("p1".into())),
2891 Some(ColumnValue::Integer(100)),
2892 Some(ColumnValue::Integer(1)),
2893 ],
2894 1,
2895 )
2896 .unwrap();
2897 client.rebuild_overlay().expect("rebuild overlay");
2898 let first = client
2899 .patch(
2900 "tasks",
2901 "t1",
2902 Map::from_iter([
2903 ("starts_at_ms".into(), json!(200)),
2904 ("version".into(), json!(2)),
2905 ]),
2906 None,
2907 )
2908 .unwrap();
2909 let (_, first_request) = client.build_request(false).unwrap();
2910 let second = client
2912 .patch(
2913 "tasks",
2914 "t1",
2915 Map::from_iter([
2916 ("starts_at_ms".into(), json!(300)),
2917 ("version".into(), json!(3)),
2918 ]),
2919 None,
2920 )
2921 .unwrap();
2922 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2923 for (id, seq, meta) in [
2924 (first, 2, first_request),
2925 (second, 3, client.build_request(false).unwrap().1),
2926 ] {
2927 let response = Message {
2928 wire_version: WIRE_VERSION,
2929 msg_kind: MsgKind::Response,
2930 frames: vec![
2931 Frame::RespHeader {
2932 required_schema_version: None,
2933 latest_schema_version: None,
2934 log_epoch: Some("epoch-1".into()),
2935 reset_required: Some(false),
2936 },
2937 Frame::PushResult {
2938 client_commit_id: id,
2939 status: PushStatus::Applied,
2940 commit_seq: Some(seq),
2941 results: vec![OpResult::Applied { op_index: 0 }],
2942 },
2943 ],
2944 };
2945 assert!(matches!(
2946 client.process_response(&mut transport, response, &meta),
2947 SyncOutcome::Ok(_)
2948 ));
2949 let row = &client
2950 .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
2951 .unwrap()[0];
2952 assert_eq!(
2953 row["starts_at_ms"],
2954 json!(300),
2955 "an applied ACK without a pull image must not expose the old base"
2956 );
2957 assert_eq!(
2958 row["version"],
2959 json!(3),
2960 "the queued domain version must remain monotonic"
2961 );
2962 }
2963 }
2964
2965 #[test]
2966 fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
2967 for stored in [None, Some("epoch-old")] {
2968 let mut client = client();
2969 client
2970 .subscribe(
2971 "tasks".into(),
2972 "tasks".into(),
2973 vec![("project_id".into(), vec!["p1".into()])],
2974 None,
2975 )
2976 .unwrap();
2977 client
2978 .conn
2979 .execute_batch(
2980 "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
2981 )
2982 .unwrap();
2983 let id = client
2984 .mutate(vec![Mutation::Upsert {
2985 table: "tasks".into(),
2986 values: Map::from_iter([
2987 ("id".into(), json!("offline")),
2988 ("project_id".into(), json!("p1")),
2989 ]),
2990 base_version: None,
2991 }])
2992 .unwrap();
2993 if let Some(epoch) = stored {
2994 client.set_meta(LOG_EPOCH_KEY, epoch);
2995 }
2996 let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
2997 let (_, meta) = client.build_request(false).unwrap();
2998 client.drain_change_batches();
2999 client.drain_sync_intents();
3000 let response = Message {
3001 wire_version: WIRE_VERSION,
3002 msg_kind: MsgKind::Response,
3003 frames: vec![Frame::RespHeader {
3004 required_schema_version: None,
3005 latest_schema_version: None,
3006 log_epoch: Some("epoch-new".into()),
3007 reset_required: Some(true),
3008 }],
3009 };
3010 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3011 let outcome = client.process_response(&mut transport, response, &meta);
3012 let SyncOutcome::Ok(report) = outcome else {
3013 panic!("epoch round failed: {outcome:?}");
3014 };
3015 assert_eq!(
3016 report.resets,
3017 if stored.is_none() {
3018 Vec::<String>::new()
3019 } else {
3020 vec!["tasks".into()]
3021 }
3022 );
3023 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3024 assert_eq!(client.pending_commit_ids(), vec![id]);
3025 assert_eq!(
3026 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3027 json!("offline")
3028 );
3029 let trigger: i64 = client
3030 .conn
3031 .query_row(
3032 "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
3033 [],
3034 |row| row.get(0),
3035 )
3036 .unwrap();
3037 if stored.is_none() {
3038 assert!(!client.upgrading);
3039 assert!(client
3040 .drain_change_batches()
3041 .iter()
3042 .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
3043 assert_eq!(
3044 serde_json::to_value(client.subscription_state("tasks")).unwrap(),
3045 before
3046 );
3047 assert_eq!(trigger, 1, "acquisition must not drop the synced table");
3048 } else {
3049 assert!(client.upgrading);
3050 assert_eq!(trigger, 0, "an epoch change must drop the old table");
3051 }
3052 assert!(matches!(
3053 client.drain_sync_intents().as_slice(),
3054 [SyncIntent::Interactive]
3055 ));
3056 }
3057 }
3058
3059 #[test]
3060 fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
3061 for key in ["logEpoch", "localRevision"] {
3062 let mut client = client();
3063 let id = client
3064 .mutate(vec![Mutation::Upsert {
3065 table: "tasks".into(),
3066 values: Map::from_iter([
3067 ("id".into(), json!("offline")),
3068 ("project_id".into(), json!("p1")),
3069 ]),
3070 base_version: None,
3071 }])
3072 .unwrap();
3073 let revision = client.local_revision();
3074 let needed = client.sync_needed;
3075 client.drain_change_batches();
3076 client.drain_sync_intents();
3077 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();
3078 assert!(client.run_log_epoch_reset("epoch-new").is_err());
3079 assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
3080 assert!(!client.upgrading);
3081 assert_eq!(client.sync_needed, needed);
3082 assert_eq!(client.local_revision(), revision);
3083 assert_eq!(client.pending_commit_ids(), [id]);
3084 assert!(client.drain_change_batches().is_empty());
3085 assert!(client.drain_sync_intents().is_empty());
3086 }
3087 }
3088
3089 #[test]
3090 fn failed_log_epoch_resets_restore_memory_and_durable_state() {
3091 for key in [
3092 "localSchemaVersion",
3093 "localSchemaDescriptor",
3094 "logEpoch",
3095 "localRevision",
3096 ] {
3097 let mut client = client();
3098 client.set_meta(LOG_EPOCH_KEY, "epoch-old");
3099 let commit = client
3100 .mutate(vec![Mutation::Upsert {
3101 table: "tasks".into(),
3102 values: Map::from_iter([
3103 ("id".into(), json!("offline")),
3104 ("project_id".into(), json!("p1")),
3105 ]),
3106 base_version: None,
3107 }])
3108 .unwrap();
3109 client.active_round = Some(uuid::Uuid::new_v4());
3110 client.overlay_dirty.set(false);
3111 let round = client.active_round;
3112 let needed = client.sync_needed;
3113 let revision = client.local_revision();
3114 let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
3115 client.drain_change_batches();
3116 client.drain_sync_intents();
3117 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();
3118 assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
3119 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
3120 assert!(!client.upgrading);
3121 assert_eq!(client.active_round, round);
3122 assert!(!client.overlay_dirty.get());
3123 assert_eq!(client.sync_needed, needed);
3124 assert_eq!(client.local_revision(), revision);
3125 assert_eq!(
3126 client.pending_commit_ids().as_slice(),
3127 std::slice::from_ref(&commit)
3128 );
3129 assert_eq!(
3130 serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
3131 rows
3132 );
3133 assert!(client.drain_change_batches().is_empty());
3134 assert!(client.drain_sync_intents().is_empty());
3135 assert!(client.conn.is_autocommit());
3136 client
3137 .conn
3138 .execute_batch("DROP TRIGGER fail_reset")
3139 .unwrap();
3140 client.run_log_epoch_reset("epoch-new").unwrap();
3141 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3142 assert_eq!(client.pending_commit_ids(), [commit]);
3143 }
3144 }
3145
3146 #[test]
3147 fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
3148 for subscriptions in [0, 2, 6, 12] {
3149 let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
3150 { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
3151 { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
3152 ] }), ClientLimits::default()).unwrap();
3153 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3154 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();
3155 for index in 0..32 {
3156 client
3157 .mutate(vec![Mutation::Upsert {
3158 table: "tasks".into(),
3159 values: Map::from_iter([
3160 ("id".into(), json!(format!("task-{index}"))),
3161 ("project_id".into(), json!("p1")),
3162 ]),
3163 base_version: None,
3164 }])
3165 .unwrap();
3166 }
3167 let (_, meta) = client.build_request(false).unwrap();
3168 let mut frames = vec![Frame::RespHeader {
3169 required_schema_version: None,
3170 latest_schema_version: None,
3171 log_epoch: Some("epoch-1".into()),
3172 reset_required: Some(false),
3173 }];
3174 frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
3175 Frame::PushResult {
3176 client_commit_id: id.clone(),
3177 status: PushStatus::Applied,
3178 commit_seq: Some(index as i64 + 1),
3179 results: vec![OpResult::Applied { op_index: 0 }],
3180 }
3181 }));
3182 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3183 client.acknowledged_replay_count.set(0);
3184 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3185 assert!(matches!(
3186 client.process_response(
3187 &mut transport,
3188 Message {
3189 wire_version: WIRE_VERSION,
3190 msg_kind: MsgKind::Response,
3191 frames
3192 },
3193 &meta
3194 ),
3195 SyncOutcome::Ok(_)
3196 ));
3197 assert_eq!(client.acknowledged_replay_count.get(), 32);
3198 assert_eq!(
3199 client
3200 .query(
3201 "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
3202 &[]
3203 )
3204 .unwrap()[0]["n"],
3205 json!(32)
3206 );
3207 assert_eq!(
3208 client
3209 .query(
3210 "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
3211 &[]
3212 )
3213 .unwrap()[0]["n"],
3214 json!(32)
3215 );
3216 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3217 client.acknowledged_replay_count.set(0);
3218 for index in 0..subscriptions {
3219 client
3220 .subscribe(
3221 format!("docs-{index}"),
3222 "docs".into(),
3223 vec![("project_id".into(), vec![format!("scope-{index}")])],
3224 None,
3225 )
3226 .unwrap();
3227 let sub = client.subs.len() - 1;
3228 client.subs[sub].effective =
3229 Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
3230 let segment = RowsSegment {
3231 table: "docs".into(),
3232 schema_version: 1,
3233 columns: client.schema.table("docs").unwrap().wire_columns.clone(),
3234 blocks: if index % 2 == 0 {
3235 vec![vec![ssp2::segment::SegmentRow {
3236 values: vec![
3237 Some(ColumnValue::String(format!("doc-{index}"))),
3238 Some(ColumnValue::String(format!("scope-{index}"))),
3239 ],
3240 server_version: 1,
3241 }]]
3242 } else {
3243 vec![]
3244 },
3245 };
3246 assert!(client.apply_segment(sub, &segment, true).is_ok());
3247 }
3248 assert_eq!(
3249 client
3250 .query("SELECT count(*) AS n FROM ack_writes", &[])
3251 .unwrap()[0]["n"],
3252 json!(0),
3253 "unrelated bootstrap must not restore/replay tasks"
3254 );
3255 assert_eq!(
3256 client.acknowledged_replay_count.get(),
3257 0,
3258 "unrelated bootstrap must not decode task ACKs"
3259 );
3260 assert_eq!(
3261 client
3262 .query("SELECT count(*) AS n FROM tasks", &[])
3263 .unwrap()[0]["n"],
3264 json!(32)
3265 );
3266 assert_eq!(
3267 client
3268 .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
3269 .unwrap()[0]["n"],
3270 json!(32)
3271 );
3272 }
3273 }
3274
3275 #[test]
3276 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
3277 let mut client = client();
3278 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3279 const COMMIT_COUNT: usize = 32;
3280
3281 for index in 0..COMMIT_COUNT {
3282 client
3283 .mutate(vec![Mutation::Upsert {
3284 table: "tasks".to_owned(),
3285 values: Map::from_iter([
3286 ("id".to_owned(), Value::from(format!("task-{index}"))),
3287 ("project_id".to_owned(), Value::from("project-1")),
3288 ]),
3289 base_version: None,
3290 }])
3291 .expect("queue commit");
3292 }
3293
3294 let (_, request_meta) = client.build_request(false).unwrap();
3295 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
3296 let mut frames = vec![Frame::RespHeader {
3297 required_schema_version: None,
3298 latest_schema_version: None,
3299 log_epoch: Some("epoch-1".to_owned()),
3300 reset_required: Some(false),
3301 }];
3302 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
3303 |(index, client_commit_id)| Frame::PushResult {
3304 client_commit_id: client_commit_id.clone(),
3305 status: PushStatus::Applied,
3306 commit_seq: Some(index as i64 + 1),
3307 results: vec![OpResult::Applied { op_index: 0 }],
3308 },
3309 ));
3310 let response = Message {
3311 wire_version: WIRE_VERSION,
3312 msg_kind: MsgKind::Response,
3313 frames,
3314 };
3315 let mut transport =
3316 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3317
3318 client.overlay_rebuild_count.set(0);
3319 client.outcome_prune_count.set(0);
3320 let outcome = client.process_response(&mut transport, response, &request_meta);
3321 assert!(matches!(outcome, SyncOutcome::Ok(_)));
3322 assert!(client.pending_commit_ids().is_empty());
3323 assert_eq!(
3324 client.overlay_rebuild_count.get(),
3325 1,
3326 "one response must reconcile its acknowledged commits with one overlay rebuild"
3327 );
3328 assert_eq!(
3329 client.outcome_prune_count.get(),
3330 1,
3331 "one response must enforce outcome retention once"
3332 );
3333 }
3334
3335 #[test]
3336 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
3337 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
3338 "columns": [
3339 {"name":"id", "type":"string", "nullable":false},
3340 {"name":"project_id", "type":"string", "nullable":false},
3341 {"name":"title", "type":"string", "nullable":false}
3342 ], "scopes": [{"pattern":"project:{project_id}"}],
3343 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
3344 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
3345 }] });
3346 let mut incremental =
3347 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3348 let mut reference =
3349 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3350 let tables = vec!["tasks".to_owned()];
3351 for index in 0..32 {
3352 let id = format!("task-{}", index % 8);
3353 let values = Map::from_iter([
3354 ("id".to_owned(), Value::from(id.clone())),
3355 (
3356 "project_id".to_owned(),
3357 Value::from(format!("p{}", index % 2)),
3358 ),
3359 ("title".to_owned(), Value::from(format!("needle{index}"))),
3360 ]);
3361 let payload = encode_row_json(
3362 incremental.schema.table("tasks").unwrap(),
3363 &id,
3364 &values,
3365 &incremental.encryption,
3366 )
3367 .unwrap();
3368 let change = ssp2::model::Change {
3369 table_index: 0,
3370 row_id: id,
3371 op: if index % 5 == 0 {
3372 Op::Delete
3373 } else {
3374 Op::Upsert
3375 },
3376 row_version: Some(index + 1),
3377 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
3378 row: Some(payload),
3379 };
3380 reference.overlay_dirty.set(true);
3381 for instance in [&mut incremental, &mut reference] {
3382 instance.begin_observation("mirror_test").unwrap();
3383 let mut batch = ChangeAccumulator::default();
3384 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
3385 instance
3386 .apply_commit_changes(&tables, std::slice::from_ref(&change))
3387 .unwrap();
3388 instance
3389 .rebuild_overlay_if_dirty()
3390 .expect("rebuild overlay");
3391 instance.finish_observation("mirror_test", batch).unwrap();
3392 }
3393 for sql in [
3394 "SELECT * FROM tasks ORDER BY id",
3395 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
3396 ] {
3397 assert_eq!(
3398 incremental.query(sql, &[]).unwrap(),
3399 reference.query(sql, &[]).unwrap()
3400 );
3401 }
3402 assert_eq!(incremental.local_revision(), reference.local_revision());
3403 assert_eq!(
3404 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
3405 serde_json::to_value(reference.drain_change_batches()).unwrap()
3406 );
3407 }
3408 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
3409 assert_eq!(reference.overlay_rebuild_count.get(), 32);
3410 }
3411
3412 #[test]
3413 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
3414 let cases = [
3415 (
3416 "string",
3417 vec![
3418 json!("1"),
3419 json!("01"),
3420 json!(""),
3421 json!("nul\u{0}id"),
3422 json!("é"),
3423 ],
3424 ),
3425 (
3426 "integer",
3427 vec![
3428 json!(i64::MIN),
3429 json!(-1),
3430 json!(0),
3431 json!(1),
3432 json!(i64::MAX),
3433 ],
3434 ),
3435 ("boolean", vec![json!(false), json!(true)]),
3436 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
3437 ];
3438 for (kind, ids) in cases {
3439 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3440 "columns":[{"name":"id","type":kind,"nullable":false},
3441 {"name":"project_id","type":"string","nullable":false}],
3442 "scopes":[{"pattern":"project:{project_id}"}]}]});
3443 let mut client =
3444 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
3445 for (index, id) in ids.iter().enumerate() {
3446 let table = client.schema.table("tasks").unwrap();
3447 client
3448 .write_base_row(
3449 "tasks",
3450 &vec![
3451 if kind == "integer" {
3452 Some(ColumnValue::Integer(id.as_i64().unwrap()))
3455 } else {
3456 json_to_column_value(&table.columns[0], Some(id)).unwrap()
3457 },
3458 Some(ColumnValue::String(format!("p{index}"))),
3459 ],
3460 1,
3461 )
3462 .unwrap();
3463 }
3464 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3465 for base in [true, false] {
3466 let full = if base {
3467 base_table("tasks")
3468 } else {
3469 visible_table("tasks")
3470 };
3471 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
3472 for row_id in [
3473 "1",
3474 "01",
3475 "1.0",
3476 "1.0000000000000002",
3477 "+1",
3478 "1e0",
3479 " 1",
3480 "-1",
3481 "0",
3482 "9223372036854775807",
3483 "-9223372036854775808",
3484 "true",
3485 "false",
3486 "",
3487 "nul\u{0}id",
3488 "é",
3489 "null",
3490 "{\"a\":1}",
3491 ] {
3492 let old_sql =
3493 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
3494 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
3495 let old = client
3496 .conn
3497 .prepare(&old_sql)
3498 .unwrap()
3499 .query_map([row_id], |row| row.get::<_, String>(0))
3500 .unwrap()
3501 .collect::<Result<Vec<_>, _>>()
3502 .unwrap();
3503 let new = client
3504 .conn
3505 .prepare_cached(&new_sql)
3506 .unwrap()
3507 .query_map([row_id], |row| row.get::<_, String>(0))
3508 .unwrap()
3509 .collect::<Result<Vec<_>, _>>()
3510 .unwrap();
3511 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
3512 let mut batch = ChangeAccumulator::default();
3513 assert_eq!(
3514 client.record_row_scopes(&mut batch, "tasks", row_id, base),
3515 !old.is_empty()
3516 );
3517 let expected = old.first().map(|scope| {
3518 BTreeMap::from([(
3519 "tasks".to_owned(),
3520 Some(BTreeSet::from([format!("project:{scope}")])),
3521 )])
3522 });
3523 assert_eq!(batch.tables, expected.unwrap_or_default());
3524 client
3525 .conn
3526 .execute_batch("SAVEPOINT lookup_delete")
3527 .unwrap();
3528 if base {
3529 client.delete_base_row("tasks", row_id).unwrap();
3530 } else {
3531 client
3532 .apply_outbox_ops(&[OutboxOp {
3533 upsert: false,
3534 table: "tasks".into(),
3535 row_id: row_id.into(),
3536 base_version: None,
3537 values: None,
3538 }])
3539 .expect("replay delete");
3540 }
3541 let remaining: i64 = client
3542 .conn
3543 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
3544 row.get(0)
3545 })
3546 .unwrap();
3547 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
3548 client
3549 .conn
3550 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
3551 .unwrap();
3552 client.overlay_dirty.set(false);
3553 }
3554 for (predicate, expected) in [
3555 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
3556 (predicate, "SEARCH"),
3557 ] {
3558 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
3559 let details = client
3560 .conn
3561 .prepare(&sql)
3562 .unwrap()
3563 .query_map(["1"], |row| row.get::<_, String>(3))
3564 .unwrap()
3565 .collect::<Result<Vec<_>, _>>()
3566 .unwrap();
3567 assert!(
3568 details.iter().any(|detail| detail.contains(expected)),
3569 "{kind}: {details:?}"
3570 );
3571 }
3572 }
3573 }
3574 }
3575
3576 #[test]
3577 fn primary_keys_reject_every_ineligible_column_type() {
3578 for ty in ["float", "bytes", "crdt", "blob_ref"] {
3582 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3583 "columns":[{"name":"id","type":ty,"nullable":false},
3584 {"name":"project_id","type":"string","nullable":false}],
3585 "scopes":[{"pattern":"project:{project_id}"}]}]});
3586 let error =
3587 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
3588 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
3589 Err(error) => error,
3590 };
3591 assert!(
3592 error.contains("has an ineligible column type (§2.4)"),
3593 "{ty}: {error}"
3594 );
3595 }
3596 for ty in ["string", "integer", "boolean", "json"] {
3597 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3598 "columns":[{"name":"id","type":ty,"nullable":false},
3599 {"name":"project_id","type":"string","nullable":false}],
3600 "scopes":[{"pattern":"project:{project_id}"}]}]});
3601 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
3602 .unwrap_or_else(|error| panic!("{ty}: {error}"));
3603 }
3604 }
3605
3606 #[test]
3607 fn pending_row_reconciliation_matches_full_fifo_replay() {
3608 for unique in [false, true] {
3609 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
3610 "name": name, "primaryKey": "id", "columns": [
3611 {"name":"id", "type":"string", "nullable":false},
3612 {"name":"project_id", "type":"string", "nullable":false},
3613 {"name":"title", "type":"string", "nullable":false}],
3614 "scopes": [{"pattern":"project:{project_id}"}],
3615 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
3616 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
3617 })))});
3618 let mut incremental =
3619 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
3620 let mut reference =
3621 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
3622 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
3623 for instance in [&mut incremental, &mut reference] {
3624 for table in &tables {
3625 for index in 0..12 {
3626 instance
3627 .write_base_row(
3628 table,
3629 &vec![
3630 Some(ColumnValue::String(format!("row{index}"))),
3631 Some(ColumnValue::String("p0".into())),
3632 Some(ColumnValue::String(format!("base{index}"))),
3633 ],
3634 1,
3635 )
3636 .unwrap();
3637 }
3638 }
3639 instance
3640 .rebuild_overlay_if_dirty()
3641 .expect("rebuild overlay");
3642 }
3643 for index in 0..80 {
3644 let mutations = (0..3)
3645 .map(|offset| {
3646 let table = tables[(index + offset) % 2].clone();
3647 let id = format!("row{}", (index + offset) % 16);
3648 if (index + offset) % 7 == 0 {
3649 Mutation::Delete {
3650 table,
3651 row_id: id,
3652 base_version: None,
3653 }
3654 } else {
3655 Mutation::Upsert {
3656 table,
3657 values: Map::from_iter([
3658 ("id".into(), Value::from(id)),
3659 ("project_id".into(), Value::from(format!("p{}", index % 3))),
3660 (
3661 "title".into(),
3662 Value::from(format!("needle{}", (index + offset) % 16)),
3663 ),
3664 ]),
3665 base_version: None,
3666 }
3667 }
3668 })
3669 .collect::<Vec<_>>();
3670 for instance in [&mut incremental, &mut reference] {
3671 instance.mutate(mutations.clone()).unwrap();
3672 }
3673 let changes = (0..3)
3676 .map(|offset| {
3677 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
3678 let id = format!("row{}", (index + offset) % 16);
3679 let values = Map::from_iter([
3680 ("id".into(), Value::from(id.clone())),
3681 (
3682 "project_id".into(),
3683 Value::from(format!("p{}", (index + 1) % 3)),
3684 ),
3685 (
3686 "title".into(),
3687 Value::from(format!("remote{index}_{offset}")),
3688 ),
3689 ]);
3690 ssp2::model::Change {
3691 table_index: table_index as u16,
3692 row_id: id.clone(),
3693 op: if (index + offset) % 5 == 0 {
3694 Op::Delete
3695 } else {
3696 Op::Upsert
3697 },
3698 row_version: Some(index as i64 + 2),
3699 scopes: vec![],
3700 row: Some(
3701 encode_row_json(
3702 incremental.schema.table(&tables[table_index]).unwrap(),
3703 &id,
3704 &values,
3705 &incremental.encryption,
3706 )
3707 .unwrap(),
3708 ),
3709 }
3710 })
3711 .collect::<Vec<_>>();
3712 reference.overlay_dirty.set(true);
3713 incremental
3714 .apply_commit_frame(&tables, &changes, None)
3715 .unwrap();
3716 reference
3717 .apply_commit_frame(&tables, &changes, None)
3718 .unwrap();
3719 for table in &tables {
3720 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
3721 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
3722 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
3723 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
3724 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
3725 }
3726 }
3727 assert_eq!(incremental.local_revision(), reference.local_revision());
3728 assert_eq!(
3729 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
3730 serde_json::to_value(reference.drain_change_batches()).unwrap()
3731 );
3732 }
3733 assert_eq!(
3734 incremental.overlay_rebuild_count.get(),
3735 if unique { 81 } else { 21 }
3736 );
3737 assert_eq!(reference.overlay_rebuild_count.get(), 81);
3738 }
3739 }
3740
3741 #[test]
3742 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
3743 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
3744 "columns":[{"name":"id", "type":"string", "nullable":false},
3745 {"name":"title", "type":"string", "nullable":false}],
3746 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
3747 let mut client =
3748 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
3749 for (id, title) in [("a", "original"), ("b", "occupied")] {
3750 client
3751 .write_base_row(
3752 "tasks",
3753 &vec![
3754 Some(ColumnValue::String(id.into())),
3755 Some(ColumnValue::String(title.into())),
3756 ],
3757 1,
3758 )
3759 .unwrap();
3760 }
3761 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3762 let pending = client
3763 .mutate(vec![Mutation::Upsert {
3764 table: "tasks".into(),
3765 values: Map::from_iter([
3766 ("id".into(), Value::from("a")),
3767 ("title".into(), Value::from("available")),
3768 ]),
3769 base_version: None,
3770 }])
3771 .unwrap();
3772 assert_eq!(
3773 client
3774 .conn
3775 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
3776 .get::<_, String>(
3777 0
3778 ))
3779 .unwrap(),
3780 "available"
3781 );
3782 for (title, expected) in [("available", "original"), ("occupied", "available")] {
3783 let change = ssp2::model::Change {
3784 table_index: 0,
3785 row_id: "b".into(),
3786 op: Op::Upsert,
3787 row_version: Some(2),
3788 scopes: vec![],
3789 row: Some(
3790 encode_row_json(
3791 client.schema.table("tasks").unwrap(),
3792 "b",
3793 &Map::from_iter([
3794 ("id".into(), Value::from("b")),
3795 ("title".into(), Value::from(title)),
3796 ]),
3797 &client.encryption,
3798 )
3799 .unwrap(),
3800 ),
3801 };
3802 client
3803 .apply_commit_frame(&["tasks".into()], &[change], None)
3804 .unwrap();
3805 assert_eq!(
3806 client
3807 .conn
3808 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
3809 .get::<_, String>(
3810 0
3811 ))
3812 .unwrap(),
3813 expected
3814 );
3815 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
3816 }
3817 assert_eq!(client.overlay_rebuild_count.get(), 3);
3818 }
3819
3820 fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
3821 let schema = json!({ "version": 1, "tables": [
3822 { "name":"tasks", "primaryKey":"id", "columns":[
3823 {"name":"id","type":"string","nullable":false},
3824 {"name":"project_id","type":"string","nullable":false}],
3825 "scopes":[{"pattern":"project:{project_id}"}] },
3826 { "name":"catalogue", "primaryKey":"id", "columns":[
3827 {"name":"id","type":"string","nullable":false},
3828 {"name":"project_id","type":"string","nullable":false},
3829 {"name":"title","type":"string","nullable":false}],
3830 "scopes":[{"pattern":"project:{project_id}"}],
3831 "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
3832 ] });
3833 let mut client =
3834 SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
3835 if unchanged_rows > 0 {
3836 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();
3837 }
3838 client.rebuild_overlay().expect("rebuild overlay");
3839 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();
3840 let mut elapsed = Vec::new();
3841 for round in 1..=rounds {
3842 let pending = client
3843 .mutate(vec![Mutation::Upsert {
3844 table: "tasks".into(),
3845 values: Map::from_iter([
3846 ("id".into(), json!("t1")),
3847 ("project_id".into(), json!("p1")),
3848 ]),
3849 base_version: None,
3850 }])
3851 .unwrap();
3852 let started = std::time::Instant::now();
3853 client
3854 .write_base_row(
3855 "tasks",
3856 &vec![
3857 Some(ColumnValue::String("t1".into())),
3858 Some(ColumnValue::String("p1".into())),
3859 ],
3860 round,
3861 )
3862 .unwrap();
3863 let mut report = SyncReport::default();
3864 client
3865 .handle_push_results(
3866 &[Frame::PushResult {
3867 client_commit_id: pending.clone(),
3868 status: PushStatus::Applied,
3869 commit_seq: Some(round),
3870 results: vec![OpResult::Applied { op_index: 0 }],
3871 }],
3872 &HashSet::from([pending.as_str()]),
3873 &HashMap::new(),
3874 &mut report,
3875 Some(&pending),
3876 )
3877 .unwrap();
3878 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3879 elapsed.push(started.elapsed().as_nanos());
3880 }
3881 assert_eq!(
3882 client
3883 .conn
3884 .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
3885 .unwrap(),
3886 0,
3887 "unchanged catalogue must never be copied"
3888 );
3889 assert_eq!(
3890 client
3891 .conn
3892 .query_row(
3893 "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
3894 [],
3895 |r| r.get::<_, i64>(0)
3896 )
3897 .unwrap(),
3898 unchanged_rows
3899 );
3900 assert_eq!(
3901 client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
3902 json!(rounds)
3903 );
3904 elapsed
3905 }
3906
3907 #[test]
3908 fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
3909 let elapsed = measure_scoped_overlay(100_000, 1);
3910 eprintln!(
3911 "table-scoped overlay with 100k unchanged rows: {} ns",
3912 elapsed[0]
3913 );
3914 }
3915
3916 #[test]
3917 #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
3918 fn benchmark_scoped_overlay() {
3919 for rows in [0, 100_000, 200_000] {
3920 let mut elapsed = measure_scoped_overlay(rows, 30);
3921 elapsed.sort_unstable();
3922 eprintln!(
3923 "{}",
3924 json!({"unchangedRows": rows, "rounds": elapsed.len(),
3925 "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
3926 "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
3927 );
3928 }
3929 }
3930
3931 #[test]
3932 fn released_context_discards_in_flight_ack_and_server_rows() {
3933 for barrier in [
3934 "preflight",
3935 "purge",
3936 "rebootstrap",
3937 "subscription",
3938 "failed-purge-replay",
3939 "failed-purge-publish",
3940 "failed-rebootstrap",
3941 ] {
3942 let mut client = client();
3943 client.set_meta(LOG_EPOCH_KEY, "epoch");
3944 client
3945 .subscribe(
3946 "tasks".into(),
3947 "tasks".into(),
3948 vec![("project_id".into(), vec!["p1".into()])],
3949 None,
3950 )
3951 .unwrap();
3952 let commit = client
3953 .mutate(vec![Mutation::Upsert {
3954 table: "tasks".into(),
3955 values: Map::from_iter([
3956 ("id".into(), json!("local")),
3957 ("project_id".into(), json!("p1")),
3958 ]),
3959 base_version: None,
3960 }])
3961 .unwrap();
3962 let prepared = client.prepare_sync_round(false).unwrap();
3963 client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
3964 let payload = encode_row_json(
3965 &client.schema.tables[0],
3966 "server",
3967 &Map::from_iter([
3968 ("id".into(), json!("server")),
3969 ("project_id".into(), json!("p1")),
3970 ]),
3971 &client.encryption,
3972 )
3973 .unwrap();
3974 let completed = crate::CompletedSyncRound {
3975 prepared,
3976 exchange: crate::round::ExchangeResult::Reply {
3977 transport_failed: false,
3978 downloads: Default::default(),
3979 response: Ok(Message {
3980 wire_version: WIRE_VERSION,
3981 msg_kind: MsgKind::Response,
3982 frames: vec![
3983 Frame::RespHeader {
3984 required_schema_version: None,
3985 latest_schema_version: None,
3986 log_epoch: Some("epoch".into()),
3987 reset_required: Some(false),
3988 },
3989 Frame::PushResult {
3990 client_commit_id: commit.clone(),
3991 status: PushStatus::Applied,
3992 commit_seq: Some(1),
3993 results: vec![OpResult::Applied { op_index: 0 }],
3994 },
3995 Frame::SubStart {
3996 id: "tasks".into(),
3997 status: SubStatus::Active,
3998 reason_code: String::new(),
3999 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
4000 bootstrap: true,
4001 },
4002 Frame::Commit {
4003 commit_seq: 1,
4004 created_at_ms: 0,
4005 actor_id: "actor".into(),
4006 tables: vec!["tasks".into()],
4007 changes: vec![ssp2::model::Change {
4008 table_index: 0,
4009 row_id: "server".into(),
4010 op: Op::Upsert,
4011 row_version: Some(1),
4012 scopes: vec![("project_id".into(), "p1".into())],
4013 row: Some(payload),
4014 }],
4015 },
4016 Frame::SubEnd {
4017 next_cursor: 1,
4018 bootstrap_state: None,
4019 },
4020 ],
4021 }),
4022 },
4023 };
4024 match barrier {
4025 "preflight" => client.begin_security_preflight(),
4026 "purge" => {
4027 client
4028 .purge_local_data(&LocalDataPurgeInput {
4029 purge_id: "pending-round".into(),
4030 targets: vec![LocalDataPurgeTarget {
4031 table: "tasks".into(),
4032 selectors: BTreeMap::from([(
4033 "project_id".into(),
4034 vec!["p1".into()],
4035 )]),
4036 }],
4037 })
4038 .unwrap();
4039 }
4040 "rebootstrap" => {
4041 client
4042 .rebootstrap_local_data(&LocalDataRebootstrapInput {
4043 rebootstrap_id: "pending-round".into(),
4044 })
4045 .unwrap();
4046 }
4047 "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
4048 let trigger = if barrier == "failed-purge-replay" {
4049 "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4050 } else {
4051 "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4052 };
4053 client.conn.execute_batch(trigger).unwrap();
4054 let revision = client.local_revision();
4055 if barrier == "failed-rebootstrap" {
4056 assert!(client
4057 .rebootstrap_local_data(&LocalDataRebootstrapInput {
4058 rebootstrap_id: "failed-round".into(),
4059 })
4060 .is_err());
4061 } else {
4062 assert!(client
4063 .purge_local_data(&LocalDataPurgeInput {
4064 purge_id: "failed-round".into(),
4065 targets: vec![LocalDataPurgeTarget {
4066 table: "tasks".into(),
4067 selectors: BTreeMap::from([(
4068 "project_id".into(),
4069 vec!["p1".into()]
4070 )]),
4071 }],
4072 })
4073 .is_err());
4074 }
4075 assert_eq!(client.local_revision(), revision);
4076 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
4077 client
4078 .conn
4079 .execute_batch("DROP TRIGGER fail_local")
4080 .unwrap();
4081 let applied = client.apply_sync_round(completed);
4082 assert!(
4083 matches!(
4084 applied,
4085 crate::AppliedSyncRound::Complete {
4086 outcome: SyncOutcome::Ok(_),
4087 ..
4088 }
4089 ),
4090 "{barrier}"
4091 );
4092 assert!(client.pending_commit_ids().is_empty());
4093 assert_eq!(
4094 client
4095 .query("SELECT id FROM tasks WHERE id = 'server'", &[])
4096 .unwrap()
4097 .len(),
4098 1
4099 );
4100 continue;
4101 }
4102 "subscription" => {
4103 client.unsubscribe("tasks");
4104 client
4105 .subscribe(
4106 "tasks".into(),
4107 "tasks".into(),
4108 vec![("project_id".into(), vec!["p2".into()])],
4109 None,
4110 )
4111 .unwrap();
4112 }
4113 _ => unreachable!(),
4114 }
4115 let pending_before_reply = client.pending_commit_ids();
4116 let revision = client.local_revision();
4117 let applied = client.apply_sync_round(completed);
4118 assert!(
4119 matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
4120 );
4121 assert_eq!(client.local_revision(), revision);
4122 assert!(client
4123 .conn
4124 .query_row(
4125 "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
4126 [],
4127 |r| r.get::<_, bool>(0)
4128 )
4129 .unwrap());
4130 assert_eq!(client.pending_commit_ids(), pending_before_reply);
4131 }
4132 }
4133
4134 #[test]
4135 fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
4136 struct DeadlineRecording {
4137 budget: std::time::Duration,
4138 seen: Vec<Option<std::time::Instant>>,
4139 }
4140 impl Transport for DeadlineRecording {
4141 fn round_deadline(&self) -> Option<std::time::Duration> {
4142 Some(self.budget)
4143 }
4144 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4145 self.seen.push(deadline);
4146 }
4147 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4148 Err(TransportError::new("transport.failed", "injected"))
4149 }
4150 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4151 self.sync(request)
4152 }
4153 fn download_segment(
4154 &mut self,
4155 _: &SegmentRequest,
4156 _: &mut dyn FnMut(u64),
4157 ) -> Result<Vec<u8>, TransportError> {
4158 Err(TransportError::new("transport.failed", "injected"))
4159 }
4160 fn blob_upload_grant(
4161 &mut self,
4162 _: &str,
4163 _: u64,
4164 _: Option<&str>,
4165 ) -> Result<crate::BlobUploadGrant, TransportError> {
4166 Ok(crate::BlobUploadGrant::Present)
4167 }
4168 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4169 Ok(())
4170 }
4171 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4172 Ok(())
4173 }
4174 fn realtime_close(&mut self) -> Result<(), TransportError> {
4175 Ok(())
4176 }
4177 }
4178
4179 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4180 {"name":"id","type":"string","nullable":false},
4181 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4182 let mut client =
4183 SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
4184 client.set_meta(LOG_EPOCH_KEY, "epoch");
4185 client
4186 .upload_blob(b"pending upload", Some("text/plain".into()), None)
4187 .unwrap();
4188
4189 let mut transport = DeadlineRecording {
4190 budget: std::time::Duration::from_secs(30),
4191 seen: Vec::new(),
4192 };
4193 let prepared = client.prepare_sync_round(false).unwrap();
4194 assert_eq!(prepared.uploads.len(), 1, "one staged upload");
4195 let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
4197 crate::AppliedSyncRound::Continue(prepared) => prepared,
4198 crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
4199 };
4200 let completed = prepared.exchange(&mut transport);
4202 let anchor = completed
4203 .prepared
4204 .round_deadline_at
4205 .expect("the upload round anchors a deadline");
4206 assert!(matches!(
4207 client.apply_sync_round(completed),
4208 crate::AppliedSyncRound::Complete { .. }
4209 ));
4210 assert_eq!(transport.seen.len(), 4, "one scope per exchange");
4211 assert_eq!(
4212 transport.seen[0],
4213 Some(anchor),
4214 "the upload anchors the round"
4215 );
4216 assert_eq!(
4217 transport.seen[1], None,
4218 "the upload exchange clears its scope"
4219 );
4220 assert_eq!(
4221 transport.seen[2],
4222 Some(anchor),
4223 "a continuation reuses the carried anchor instead of refreshing it"
4224 );
4225 assert_eq!(transport.seen[3], None, "the continuation clears its scope");
4226
4227 transport.budget = std::time::Duration::from_secs(60);
4230 let prepared = client.prepare_sync_round(false).unwrap();
4231 let completed = prepared.exchange(&mut transport);
4232 let fresh = completed
4233 .prepared
4234 .round_deadline_at
4235 .expect("the next round anchors a deadline");
4236 client.apply_sync_round(completed);
4237 assert!(
4238 fresh >= anchor + std::time::Duration::from_secs(30),
4239 "the next round anchors a fresh deadline from the new budget"
4240 );
4241 assert_eq!(transport.seen[4], Some(fresh));
4242 assert_eq!(
4243 transport.seen[5], None,
4244 "the new round clears its scope too"
4245 );
4246 }
4247
4248 #[derive(Default)]
4251 struct RoundDeadlineProbe {
4252 budget: Option<std::time::Duration>,
4253 budget_queries: std::cell::Cell<usize>,
4254 scoped: Vec<Option<std::time::Instant>>,
4255 }
4256
4257 impl Transport for RoundDeadlineProbe {
4258 fn round_deadline(&self) -> Option<std::time::Duration> {
4259 self.budget_queries.set(self.budget_queries.get() + 1);
4260 self.budget
4261 }
4262 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4263 self.scoped.push(deadline);
4264 }
4265 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4266 Err(TransportError::new("transport.failed", "injected"))
4267 }
4268 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4269 self.sync(request)
4270 }
4271 fn download_segment(
4272 &mut self,
4273 _: &SegmentRequest,
4274 _: &mut dyn FnMut(u64),
4275 ) -> Result<Vec<u8>, TransportError> {
4276 Err(TransportError::new("transport.failed", "injected"))
4277 }
4278 fn blob_upload_grant(
4279 &mut self,
4280 _: &str,
4281 _: u64,
4282 _: Option<&str>,
4283 ) -> Result<crate::BlobUploadGrant, TransportError> {
4284 Ok(crate::BlobUploadGrant::Present)
4285 }
4286 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4287 Ok(())
4288 }
4289 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4290 Ok(())
4291 }
4292 fn realtime_close(&mut self) -> Result<(), TransportError> {
4293 Ok(())
4294 }
4295 }
4296
4297 #[test]
4298 fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
4299 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4300 {"name":"id","type":"string","nullable":false},
4301 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4302 let mut client =
4303 SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
4304 client.set_meta(LOG_EPOCH_KEY, "epoch");
4305 client
4306 .upload_blob(b"pending upload", Some("text/plain".into()), None)
4307 .unwrap();
4308 client
4309 .conn
4310 .execute_batch("DELETE FROM _syncular_blobs")
4311 .unwrap();
4312 let prepared = client.prepare_sync_round(false).unwrap();
4313 assert!(
4314 prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
4315 "the staged body must fail locally"
4316 );
4317 let mut transport = RoundDeadlineProbe {
4318 budget: Some(std::time::Duration::from_secs(30)),
4319 ..Default::default()
4320 };
4321 let completed = prepared.exchange(&mut transport);
4322 assert!(matches!(
4323 completed.exchange,
4324 crate::round::ExchangeResult::Upload { result: Err(_), .. }
4325 ));
4326 assert_eq!(
4327 transport.budget_queries.get(),
4328 0,
4329 "a round with no network work never asks for a budget"
4330 );
4331 assert_eq!(transport.scoped, vec![None]);
4332 }
4333
4334 #[test]
4335 fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
4336 struct HugeBudget;
4337 impl Transport for HugeBudget {
4338 fn round_deadline(&self) -> Option<std::time::Duration> {
4339 Some(std::time::Duration::MAX)
4340 }
4341 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4342 panic!("no network for an invalid budget")
4343 }
4344 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4345 self.sync(request)
4346 }
4347 fn download_segment(
4348 &mut self,
4349 _: &SegmentRequest,
4350 _: &mut dyn FnMut(u64),
4351 ) -> Result<Vec<u8>, TransportError> {
4352 panic!("no network for an invalid budget")
4353 }
4354 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4355 Ok(())
4356 }
4357 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4358 Ok(())
4359 }
4360 fn realtime_close(&mut self) -> Result<(), TransportError> {
4361 Ok(())
4362 }
4363 }
4364
4365 let mut client = client();
4366 client.set_meta(LOG_EPOCH_KEY, "epoch");
4367 let prepared = client.prepare_sync_round(false).unwrap();
4368 let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
4369 crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
4370 crate::AppliedSyncRound::Continue(_) => {
4371 panic!("an unrepresentable budget must not continue")
4372 }
4373 };
4374 match outcome {
4375 SyncOutcome::Failed {
4376 error_code,
4377 message,
4378 ..
4379 } => {
4380 assert_eq!(error_code, "sync.invalid_request");
4381 assert_eq!(message, "transport round deadline is out of range");
4382 }
4383 _ => panic!("an unrepresentable budget must fail synchronously"),
4384 }
4385 }
4386
4387 #[test]
4388 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
4389 for failure in ["visible", "revision", "commit"] {
4390 let mut client = client();
4391 let id = client
4392 .mutate(vec![Mutation::Upsert {
4393 table: "tasks".into(),
4394 values: Map::from_iter([
4395 ("id".into(), Value::from("t1")),
4396 ("project_id".into(), Value::from("pending")),
4397 ]),
4398 base_version: None,
4399 }])
4400 .unwrap();
4401 client.drain_change_batches();
4402 let revision = client.local_revision();
4403 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
4404 let trigger = match failure {
4405 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
4406 "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"),
4407 _ => "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(),
4408 };
4409 client.conn.execute_batch(&trigger).unwrap();
4410 let change = ssp2::model::Change {
4411 table_index: 0,
4412 row_id: "t1".into(),
4413 op: Op::Upsert,
4414 row_version: Some(2),
4415 scopes: vec![],
4416 row: Some(
4417 encode_row_json(
4418 client.schema.table("tasks").unwrap(),
4419 "t1",
4420 &Map::from_iter([
4421 ("id".into(), Value::from("t1")),
4422 ("project_id".into(), Value::from("remote")),
4423 ]),
4424 &client.encryption,
4425 )
4426 .unwrap(),
4427 ),
4428 };
4429 assert!(client
4430 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
4431 .is_err());
4432 assert!(client.conn.is_autocommit());
4433 assert!(!client.overlay_dirty.get());
4434 assert_eq!(client.local_revision(), revision);
4435 assert!(client.drain_change_batches().is_empty());
4436 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
4437 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
4438 assert!(client
4439 .query("SELECT * FROM _syncular_base_tasks", &[])
4440 .unwrap()
4441 .is_empty());
4442 client
4443 .conn
4444 .execute_batch("DROP TRIGGER fail_pending")
4445 .unwrap();
4446 client
4447 .apply_commit_frame(&["tasks".into()], &[change], None)
4448 .unwrap();
4449 assert_eq!(client.local_revision(), revision + 1);
4450 assert_eq!(client.pending_commit_ids(), vec![id]);
4451 let mut expected = before[0].clone();
4454 expected.insert("_syncular_version".to_owned(), Value::from(2));
4455 assert_eq!(
4456 client.query("SELECT * FROM tasks", &[]).unwrap(),
4457 vec![expected]
4458 );
4459 assert_eq!(
4460 client
4461 .conn
4462 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
4463 .get::<_, String>(0))
4464 .unwrap(),
4465 "remote"
4466 );
4467 }
4468 }
4469
4470 #[test]
4471 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
4472 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
4473 "columns": [
4474 {"name":"id", "type":"string", "nullable":false},
4475 {"name":"project_id", "type":"string", "nullable":false},
4476 {"name":"title", "type":"string", "nullable":false}],
4477 "scopes": [{"pattern":"project:{project_id}"}],
4478 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
4479 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
4480 let mut incremental =
4481 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
4482 let mut reference =
4483 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
4484 for instance in [&mut incremental, &mut reference] {
4485 for index in 0..16 {
4486 instance
4487 .write_base_row(
4488 "tasks",
4489 &vec![
4490 Some(ColumnValue::String(format!("task{index}"))),
4491 Some(ColumnValue::String("p0".to_owned())),
4492 Some(ColumnValue::String(format!("base{index}"))),
4493 ],
4494 1,
4495 )
4496 .unwrap();
4497 }
4498 instance
4499 .rebuild_overlay_if_dirty()
4500 .expect("rebuild overlay");
4501 instance.overlay_rebuild_count.set(0);
4502 }
4503 for index in 0..100 {
4504 let mutations = (0..1 + index % 4)
4505 .map(|offset| {
4506 let id = format!("task{}", (index + offset) % 24);
4507 if (index + offset) % 7 == 0 {
4508 Mutation::Delete {
4509 table: "tasks".to_owned(),
4510 row_id: id,
4511 base_version: None,
4512 }
4513 } else {
4514 Mutation::Upsert {
4515 table: "tasks".to_owned(),
4516 values: Map::from_iter([
4517 ("id".to_owned(), Value::from(id)),
4518 (
4519 "project_id".to_owned(),
4520 Value::from(format!("p{}", index % 2)),
4521 ),
4522 (
4523 "title".to_owned(),
4524 Value::from(format!("needle{}", (index + offset) % 24)),
4525 ),
4526 ]),
4527 base_version: None,
4528 }
4529 }
4530 })
4531 .collect::<Vec<_>>();
4532 reference.overlay_dirty.set(true);
4533 incremental.mutate(mutations.clone()).unwrap();
4534 reference.mutate(mutations).unwrap();
4535 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
4536 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
4537 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
4538 }
4539 assert_eq!(incremental.local_revision(), reference.local_revision());
4540 assert_eq!(
4541 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4542 serde_json::to_value(reference.drain_change_batches()).unwrap()
4543 );
4544 }
4545 assert_eq!(incremental.outbox.len(), 100);
4546 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4547 assert_eq!(reference.overlay_rebuild_count.get(), 100);
4548 }
4549
4550 #[test]
4551 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
4552 for fail_at in ["outbox", "revision"] {
4553 let mut client = client();
4554 let values = Map::from_iter([
4555 ("id".to_owned(), Value::from("t1")),
4556 ("project_id".to_owned(), Value::from("p1")),
4557 ]);
4558 let mutation = Mutation::Upsert {
4559 table: "tasks".to_owned(),
4560 values,
4561 base_version: None,
4562 };
4563 let trigger = if fail_at == "outbox" {
4564 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
4565 } else {
4566 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
4567 };
4568 client.conn.execute_batch(&trigger).unwrap();
4569 let revision = client.local_revision();
4570 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
4571 assert_eq!(client.local_revision(), revision);
4572 assert!(client.outbox.is_empty());
4573 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
4574 assert_eq!(
4575 client
4576 .conn
4577 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
4578 .get::<_, i64>(0))
4579 .unwrap(),
4580 0
4581 );
4582 assert!(client.drain_change_batches().is_empty());
4583 assert!(!client.overlay_dirty.get());
4584 client
4585 .conn
4586 .execute_batch("DROP TRIGGER fail_append")
4587 .unwrap();
4588 client.mutate(vec![mutation]).unwrap();
4589 assert_eq!(client.outbox.len(), 1);
4590 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
4591 }
4592 }
4593
4594 #[test]
4595 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
4596 let mut client = client();
4597 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
4598 let values = Map::from_iter([
4599 ("id".to_owned(), Value::from("t1")),
4600 ("project_id".to_owned(), Value::from("p1")),
4601 ]);
4602 let payload = encode_row_json(
4603 client.schema.table("tasks").unwrap(),
4604 "t1",
4605 &values,
4606 &client.encryption,
4607 )
4608 .unwrap();
4609 let first = ssp2::model::Change {
4610 table_index: 0,
4611 row_id: "t1".to_owned(),
4612 op: Op::Upsert,
4613 row_version: Some(1),
4614 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
4615 row: Some(payload),
4616 };
4617 let mut invalid = first.clone();
4618 invalid.table_index = 1;
4619 let revision = client.local_revision();
4620 client.begin_observation("failed_mirror").unwrap();
4621 assert!(client
4622 .apply_commit_changes(&tables, &[first, invalid])
4623 .is_err());
4624 client.rollback_observation("failed_mirror");
4625 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4626 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
4627 assert_eq!(
4628 client
4629 .conn
4630 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
4631 .get::<_, i64>(
4632 0
4633 ))
4634 .unwrap(),
4635 0
4636 );
4637 assert_eq!(client.local_revision(), revision);
4638 assert!(client.drain_change_batches().is_empty());
4639 }
4640
4641 #[test]
4642 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
4643 for failure in ["journal", "revision", "commit", "later-frame"] {
4644 let mut client = client();
4645 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4646 for id in ["first", "second"] {
4647 client
4648 .mutate(vec![Mutation::Upsert {
4649 table: "tasks".to_owned(),
4650 values: Map::from_iter([
4651 ("id".to_owned(), Value::from(id)),
4652 ("project_id".to_owned(), Value::from("p1")),
4653 ]),
4654 base_version: None,
4655 }])
4656 .unwrap();
4657 }
4658 let (_, meta) = client.build_request(false).unwrap();
4659 client
4661 .mutate(vec![Mutation::Upsert {
4662 table: "tasks".to_owned(),
4663 values: Map::from_iter([
4664 ("id".to_owned(), Value::from("later")),
4665 ("project_id".to_owned(), Value::from("p1")),
4666 ]),
4667 base_version: None,
4668 }])
4669 .unwrap();
4670 let ids = client.pending_commit_ids();
4671 let rows = client
4672 .query("SELECT * FROM tasks ORDER BY id", &[])
4673 .unwrap();
4674 let revision = client.local_revision();
4675 client.drain_change_batches();
4676 if failure != "later-frame" {
4677 client.conn.execute_batch(match failure {
4678 "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",
4679 "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",
4680 _ => "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",
4681 }).unwrap();
4682 }
4683 let mut response = Message {
4684 wire_version: WIRE_VERSION,
4685 msg_kind: MsgKind::Response,
4686 frames: vec![Frame::RespHeader {
4687 required_schema_version: None,
4688 latest_schema_version: None,
4689 log_epoch: Some("epoch-1".to_owned()),
4690 reset_required: Some(false),
4691 }],
4692 };
4693 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
4694 response.frames.push(Frame::PushResult {
4695 client_commit_id: id.to_owned(),
4696 status: PushStatus::Applied,
4697 commit_seq: Some(1),
4698 results: vec![OpResult::Applied { op_index: 0 }],
4699 });
4700 }
4701 if failure == "later-frame" {
4702 response.frames.push(Frame::Error {
4703 code: "sync.invalid_request".to_owned(),
4704 message: "later error".to_owned(),
4705 category: "protocol".to_owned(),
4706 retryable: false,
4707 recommended_action: "retry".to_owned(),
4708 details: None,
4709 });
4710 }
4711 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
4712 let outcome = client.process_response(&mut transport, response.clone(), &meta);
4713 let expected = match failure {
4716 "later-frame" => "sync.invalid_request",
4717 "commit" => "storage.failed",
4718 _ => "client.outcome_persistence_failed",
4719 };
4720 assert!(
4721 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
4722 "{failure}: {outcome:?}"
4723 );
4724 if failure == "later-frame" {
4725 assert_eq!(client.pending_commit_ids(), ids[2..]);
4726 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
4727 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
4728 let batches = client.drain_change_batches();
4729 assert_eq!(batches.len(), 1);
4730 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
4731 assert_eq!(client.local_revision(), revision + 1);
4732 response.frames.pop();
4733 } else {
4734 assert_eq!(client.pending_commit_ids(), ids);
4735 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
4736 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
4737 assert_eq!(client.local_revision(), revision);
4738 assert!(client.drain_change_batches().is_empty());
4739 assert_eq!(
4740 client
4741 .query("SELECT * FROM tasks ORDER BY id", &[])
4742 .unwrap(),
4743 rows
4744 );
4745 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
4746 }
4747 for frame in &mut response.frames {
4748 if let Frame::PushResult { status, .. } = frame {
4749 *status = PushStatus::Cached;
4750 }
4751 }
4752 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
4753 else {
4754 panic!("retry failed");
4755 };
4756 assert_eq!(
4757 report.applied,
4758 if failure == "later-frame" {
4759 vec![]
4760 } else {
4761 ids[..2].to_vec()
4762 }
4763 );
4764 assert_eq!(client.pending_commit_ids(), ids[2..]);
4765 let batches = client.drain_change_batches();
4766 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
4767 if let Some(batch) = batches.first() {
4768 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
4769 }
4770 assert_eq!(client.local_revision(), revision + 1);
4771 }
4772 }
4773
4774 #[test]
4775 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
4776 for failure in ["revision", "journal", "commit"] {
4777 for status in ["applied", "cached", "rejected", "conflict"] {
4778 let mut client = client();
4779 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4780 for id in ["first", "later"] {
4781 client
4782 .mutate(vec![Mutation::Upsert {
4783 table: "tasks".to_owned(),
4784 values: Map::from_iter([
4785 ("id".to_owned(), Value::from(id)),
4786 ("project_id".to_owned(), Value::from("p1")),
4787 ]),
4788 base_version: None,
4789 }])
4790 .unwrap();
4791 }
4792 let (_, meta) = client.build_request(false).unwrap();
4793 let ids = client.pending_commit_ids();
4794 let rows = client
4795 .query("SELECT * FROM tasks ORDER BY id", &[])
4796 .unwrap();
4797 let revision = client.local_revision();
4798 client.drain_change_batches();
4799 let (push_status, results) = match status {
4800 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
4801 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
4802 "conflict" => (
4803 PushStatus::Rejected,
4804 vec![OpResult::Conflict {
4805 op_index: 0,
4806 code: "sync.version_conflict".to_owned(),
4807 message: "conflict".to_owned(),
4808 server_version: 1,
4809 server_row: encode_row_json(
4810 client.schema.table("tasks").unwrap(),
4811 "first",
4812 &Map::from_iter([
4813 ("id".to_owned(), Value::from("first")),
4814 ("project_id".to_owned(), Value::from("p1")),
4815 ]),
4816 &client.encryption,
4817 )
4818 .unwrap(),
4819 conflict_columns: Vec::new(),
4820 }],
4821 ),
4822 _ => (
4823 PushStatus::Rejected,
4824 vec![OpResult::Error {
4825 op_index: 0,
4826 code: "sync.validation_failed".to_owned(),
4827 message: "rejected".to_owned(),
4828 retryable: false,
4829 }],
4830 ),
4831 };
4832 let response = Message {
4833 wire_version: WIRE_VERSION,
4834 msg_kind: MsgKind::Response,
4835 frames: vec![
4836 Frame::RespHeader {
4837 required_schema_version: None,
4838 latest_schema_version: None,
4839 log_epoch: Some("epoch-1".to_owned()),
4840 reset_required: Some(false),
4841 },
4842 Frame::PushResult {
4843 client_commit_id: ids[0].clone(),
4844 status: push_status,
4845 commit_seq: Some(1),
4846 results,
4847 },
4848 ],
4849 };
4850 client.conn.execute_batch(match failure {
4851 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
4852 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
4853 _ => "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",
4854 }).unwrap();
4855 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
4856 let outcome = client.process_response(&mut transport, response.clone(), &meta);
4857 assert_eq!(
4858 client.pending_commit_ids(),
4859 ids,
4860 "{failure}/{status}: pending identity"
4861 );
4862 assert_eq!(
4863 client.local_revision(),
4864 revision,
4865 "{failure}/{status}: revision"
4866 );
4867 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
4868 assert!(client.conflicts().is_empty());
4869 assert!(client.rejections().is_empty());
4870 assert!(client.drain_change_batches().is_empty());
4871 assert_eq!(
4872 client
4873 .query("SELECT * FROM tasks ORDER BY id", &[])
4874 .unwrap(),
4875 rows
4876 );
4877 let expected = if failure == "commit" {
4880 "storage.failed"
4881 } else {
4882 "client.outcome_persistence_failed"
4883 };
4884 assert!(
4885 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
4886 "{failure}/{status}: failed round"
4887 );
4888 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
4889 assert!(matches!(
4890 client.process_response(&mut transport, response, &meta),
4891 SyncOutcome::Ok(_)
4892 ));
4893 assert_eq!(client.pending_commit_ids(), ids[1..]);
4894 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
4895 assert_eq!(client.drain_change_batches().len(), 1);
4896 }
4897 }
4898 }
4899
4900 #[test]
4901 fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
4902 let mut client = client();
4903 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4904 for index in 0..4 {
4905 client
4906 .mutate(vec![Mutation::Upsert {
4907 table: "tasks".to_owned(),
4908 values: Map::from_iter([
4909 ("id".to_owned(), Value::from(format!("task-{index}"))),
4910 ("project_id".to_owned(), Value::from("project-1")),
4911 ]),
4912 base_version: None,
4913 }])
4914 .expect("queue commit");
4915 }
4916
4917 let (_, request_meta) = client.build_request(false).unwrap();
4918 let ids = &request_meta.pushed_ids;
4919 assert_eq!(ids.len(), 4);
4920 let response = Message {
4921 wire_version: WIRE_VERSION,
4922 msg_kind: MsgKind::Response,
4923 frames: vec![
4924 Frame::RespHeader {
4925 required_schema_version: None,
4926 latest_schema_version: None,
4927 log_epoch: Some("epoch-1".to_owned()),
4928 reset_required: Some(false),
4929 },
4930 Frame::PushResult {
4931 client_commit_id: ids[0].clone(),
4932 status: PushStatus::Applied,
4933 commit_seq: Some(1),
4934 results: vec![OpResult::Applied { op_index: 0 }],
4935 },
4936 Frame::PushResult {
4937 client_commit_id: ids[1].clone(),
4938 status: PushStatus::Cached,
4939 commit_seq: Some(2),
4940 results: vec![OpResult::Applied { op_index: 0 }],
4941 },
4942 Frame::PushResult {
4943 client_commit_id: ids[2].clone(),
4944 status: PushStatus::Rejected,
4945 commit_seq: None,
4946 results: vec![OpResult::Error {
4947 op_index: 0,
4948 code: "sync.validation_failed".to_owned(),
4949 message: "rejected".to_owned(),
4950 retryable: false,
4951 }],
4952 },
4953 Frame::PushResult {
4954 client_commit_id: ids[3].clone(),
4955 status: PushStatus::Rejected,
4956 commit_seq: None,
4957 results: vec![OpResult::Error {
4958 op_index: 0,
4959 code: "sync.idempotency_cache_miss".to_owned(),
4960 message: "retry".to_owned(),
4961 retryable: true,
4962 }],
4963 },
4964 ],
4965 };
4966 let mut transport =
4967 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
4968
4969 client.overlay_rebuild_count.set(0);
4970 client.outcome_prune_count.set(0);
4971 let outcome = client.process_response(&mut transport, response, &request_meta);
4972 let SyncOutcome::Ok(report) = outcome else {
4973 panic!("mixed push-result response failed");
4974 };
4975
4976 assert_eq!(report.applied, ids[..2]);
4977 assert_eq!(report.rejected, ids[2..3]);
4978 assert_eq!(report.retryable, ids[3..4]);
4979 assert_eq!(client.pending_commit_ids(), ids[3..4]);
4980 assert_eq!(
4981 client
4982 .query("SELECT id FROM tasks ORDER BY id", &[])
4983 .expect("query visible overlay"),
4984 ["task-0", "task-1", "task-3"]
4985 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
4986 .to_vec()
4987 );
4988 assert_eq!(client.overlay_rebuild_count.get(), 2);
4990 assert_eq!(client.outcome_prune_count.get(), 1);
4991 }
4992
4993 fn replay_schema(fts: bool) -> Value {
4994 json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
4995 {"name":"id","type":"string","nullable":false},
4996 {"name":"project_id","type":"string","nullable":false},
4997 {"name":"title","type":"string","nullable":false},
4998 {"name":"done","type":"boolean","nullable":false}
4999 ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
5000 }
5001
5002 #[test]
5003 fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
5004 for schema_reset in [false, true] {
5005 let mut schema = replay_schema(false);
5006 let mut client =
5007 SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
5008 let id = client
5009 .mutate(vec![Mutation::Upsert {
5010 table: "tasks".into(),
5011 values: Map::from_iter([
5012 ("id".into(), json!("queued")),
5013 ("project_id".into(), json!("p1")),
5014 ("title".into(), json!("old")),
5015 ("done".into(), json!(false)),
5016 ]),
5017 base_version: None,
5018 }])
5019 .unwrap();
5020 client.set_meta(LOG_EPOCH_KEY, "old");
5021 let revision = client.local_revision();
5022 let needed = client.sync_needed;
5023 client.drain_change_batches();
5024 client.drain_sync_intents();
5025 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();
5026 if schema_reset {
5027 schema["version"] = json!(2);
5028 schema["tables"][0]["columns"]
5029 .as_array_mut()
5030 .unwrap()
5031 .retain(|column| column["name"] != "title");
5032 client.schema = parse_schema_json(&schema).unwrap();
5033 assert!(client.run_schema_reset().is_err());
5034 } else {
5035 assert!(client.run_log_epoch_reset("new").is_err());
5036 }
5037 assert!(!client.upgrading);
5038 assert_eq!(client.sync_needed, needed);
5039 assert_eq!(client.local_revision(), revision);
5040 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5041 assert!(client.rejections.is_empty());
5042 assert!(client.commit_outcome(&id).unwrap().is_none());
5043 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
5044 assert_eq!(
5045 client
5046 .query("SELECT title FROM tasks WHERE id='queued'", &[])
5047 .unwrap()[0]["title"],
5048 "old"
5049 );
5050 assert_eq!(
5051 client
5052 .conn
5053 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5054 .get::<_, i64>(0))
5055 .unwrap(),
5056 1
5057 );
5058 assert!(client.drain_change_batches().is_empty());
5059 assert!(client.drain_sync_intents().is_empty());
5060 assert!(client.conn.is_autocommit());
5061 }
5062 }
5063
5064 #[test]
5065 fn cached_blob_cleanup_propagates_delete_failures() {
5066 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5067 {"name":"id","type":"string","nullable":false},
5068 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5069 let mut client =
5070 SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
5071 client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
5072 client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
5073 client
5074 .subscribe("attachments".into(), "attachments".into(), vec![], None)
5075 .unwrap();
5076 client.subs[0].effective = Some(vec![]);
5077 client.persist_sub(&client.subs[0]).unwrap();
5078 let revision = client.local_revision();
5079 let (_, meta) = client.build_request(false).unwrap();
5080 let mut transport = CountingRealtimeTransport::default();
5081 let mut report = SyncReport::default();
5082 assert!(client
5083 .process_section(
5084 &mut transport,
5085 "attachments",
5086 SubStatus::Revoked,
5087 "sync.scope_revoked",
5088 vec![],
5089 vec![],
5090 Some((0, None)),
5091 &meta,
5092 &mut report
5093 )
5094 .is_err());
5095 assert_eq!(client.subs[0].state, SubState::Active);
5096 assert_eq!(client.local_revision(), revision);
5097 assert!(report.revoked.is_empty());
5098 assert!(client.conn.is_autocommit());
5099 client
5100 .conn
5101 .execute_batch("DROP TRIGGER refuse_blob_cleanup")
5102 .unwrap();
5103 assert!(client
5104 .process_section(
5105 &mut transport,
5106 "attachments",
5107 SubStatus::Revoked,
5108 "sync.scope_revoked",
5109 vec![],
5110 vec![],
5111 Some((0, None)),
5112 &meta,
5113 &mut report
5114 )
5115 .is_ok());
5116 assert_eq!(client.subs[0].state, SubState::Revoked);
5117 assert_eq!(
5118 client
5119 .conn
5120 .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
5121 .get::<_, i64>(0))
5122 .unwrap(),
5123 0
5124 );
5125 }
5126
5127 #[test]
5128 fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
5129 let path =
5130 std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
5131 let schema = replay_schema(false);
5132 let mut client = SyncClient::open_path(
5133 "replay".into(),
5134 &schema,
5135 ClientLimits::default(),
5136 path.to_str().unwrap(),
5137 )
5138 .unwrap();
5139 let ids = ["early", "bad"].map(|id| {
5140 client
5141 .mutate(vec![Mutation::Upsert {
5142 table: "tasks".into(),
5143 values: Map::from_iter([
5144 ("id".into(), json!(id)),
5145 ("project_id".into(), json!("p1")),
5146 ("title".into(), json!(id)),
5147 ("done".into(), json!(false)),
5148 ]),
5149 base_version: None,
5150 }])
5151 .unwrap()
5152 });
5153 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();
5154 let revision = client.local_revision();
5155 drop(client);
5156 let error = SyncClient::open_path(
5157 "replay".into(),
5158 &schema,
5159 ClientLimits::default(),
5160 path.to_str().unwrap(),
5161 )
5162 .err()
5163 .expect("reopen must fail");
5164 assert!(error.contains("replay failed"));
5165 let conn = Connection::open(&path).unwrap();
5166 assert_eq!(
5167 conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
5168 .unwrap(),
5169 0
5170 );
5171 assert_eq!(
5172 conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5173 .get::<_, i64>(0))
5174 .unwrap(),
5175 2
5176 );
5177 assert_eq!(
5178 meta_get(&conn, LOCAL_REVISION_KEY),
5179 Some(revision.to_string())
5180 );
5181 conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
5182 drop(conn);
5183 let reopened = SyncClient::open_path(
5184 "replay".into(),
5185 &schema,
5186 ClientLimits::default(),
5187 path.to_str().unwrap(),
5188 )
5189 .unwrap();
5190 assert_eq!(reopened.pending_commit_ids(), ids);
5191 assert_eq!(
5192 reopened
5193 .query("SELECT id FROM tasks ORDER BY id", &[])
5194 .unwrap()
5195 .len(),
5196 2
5197 );
5198 drop(reopened);
5199 std::fs::remove_file(path).unwrap();
5200 }
5201
5202 #[test]
5203 fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
5204 let schema = replay_schema(false);
5205 let mut client =
5206 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5207 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5208 client
5209 .write_base_row(
5210 "tasks",
5211 &vec![
5212 Some(ColumnValue::String("queued".into())),
5213 Some(ColumnValue::String("p1".into())),
5214 Some(ColumnValue::String("server".into())),
5215 Some(ColumnValue::Boolean(false)),
5216 ],
5217 1,
5218 )
5219 .unwrap();
5220 client.rebuild_overlay().unwrap();
5221 let id = client
5222 .patch(
5223 "tasks",
5224 "queued",
5225 Map::from_iter([("title".into(), json!("local"))]),
5226 Some(1),
5227 )
5228 .unwrap();
5229 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
5230 let base = client
5231 .query("SELECT * FROM _syncular_base_tasks", &[])
5232 .unwrap();
5233 let revision = client.local_revision();
5234 client.drain_change_batches();
5235 client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
5236 let remote = vec![
5237 Some(ColumnValue::String("queued".into())),
5238 Some(ColumnValue::String("p1".into())),
5239 Some(ColumnValue::String("remote".into())),
5240 Some(ColumnValue::Boolean(false)),
5241 ];
5242 let bytes = crate::values::encode_row_json(
5243 client.schema.table("tasks").unwrap(),
5244 "queued",
5245 &Map::from_iter(
5246 client
5247 .schema
5248 .table("tasks")
5249 .unwrap()
5250 .columns
5251 .iter()
5252 .zip(&remote)
5253 .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
5254 ),
5255 &client.encryption,
5256 )
5257 .unwrap();
5258 let change = ssp2::model::Change {
5259 table_index: 0,
5260 op: Op::Upsert,
5261 row_id: "queued".into(),
5262 row_version: Some(2),
5263 scopes: vec![("project_id".into(), "p1".into())],
5264 row: Some(bytes),
5265 };
5266 assert!(client
5267 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
5268 .is_err());
5269 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5270 assert_eq!(
5271 client
5272 .query("SELECT * FROM _syncular_base_tasks", &[])
5273 .unwrap(),
5274 base
5275 );
5276 assert_eq!(client.local_revision(), revision);
5277 assert!(client.drain_change_batches().is_empty());
5278 let response = Message {
5279 wire_version: 2,
5280 msg_kind: MsgKind::Response,
5281 frames: vec![
5282 Frame::RespHeader {
5283 required_schema_version: None,
5284 latest_schema_version: None,
5285 log_epoch: Some("epoch-1".into()),
5286 reset_required: Some(false),
5287 },
5288 Frame::PushResult {
5289 client_commit_id: id.clone(),
5290 status: PushStatus::Applied,
5291 commit_seq: Some(2),
5292 results: vec![OpResult::Applied { op_index: 0 }],
5293 },
5294 ],
5295 };
5296 let (_, meta) = client.build_request(false).unwrap();
5297 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5298 assert!(
5299 matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
5300 );
5301 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5302 assert!(client.commit_outcome(&id).unwrap().is_none());
5303 assert_eq!(
5304 client
5305 .conn
5306 .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
5307 .get::<_, i64>(
5308 0
5309 ))
5310 .unwrap(),
5311 0
5312 );
5313 assert_eq!(
5314 client
5315 .conn
5316 .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
5317 row.get::<_, i64>(0)
5318 })
5319 .unwrap(),
5320 0
5321 );
5322 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5323 assert_eq!(client.local_revision(), revision);
5324 assert!(client.drain_change_batches().is_empty());
5325 client
5326 .conn
5327 .execute_batch("DROP TRIGGER fail_replay")
5328 .unwrap();
5329 assert!(matches!(
5330 client.process_response(&mut transport, response, &meta),
5331 SyncOutcome::Ok(_)
5332 ));
5333 client
5334 .apply_commit_frame(&["tasks".into()], &[change], Some(2))
5335 .unwrap();
5336 assert!(client.pending_commit_ids().is_empty());
5337 assert_eq!(
5338 client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
5339 json!("remote")
5340 );
5341 }
5342
5343 #[test]
5344 fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
5345 let client = SyncClient::new(
5346 "replay".into(),
5347 &replay_schema(false),
5348 ClientLimits::default(),
5349 )
5350 .unwrap();
5351 let table = client.schema.table("tasks").unwrap();
5352 let patch = OutboxOp {
5353 table: "tasks".into(),
5354 row_id: "absent".into(),
5355 upsert: true,
5356 base_version: Some(1),
5357 values: Some(Map::from_iter([
5358 ("id".into(), json!("absent")),
5359 ("title".into(), json!("patch")),
5360 ])),
5361 };
5362 assert!(client.visible_row(table, "absent").unwrap().is_none());
5363 client
5364 .apply_outbox_ops([&patch])
5365 .expect("sparse replay over truly absent row");
5366 client
5367 .conn
5368 .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
5369 .unwrap();
5370 assert!(client
5371 .visible_row(table, "bad")
5372 .unwrap_err()
5373 .starts_with("sync.local_corrupt:"));
5374 let mut invalid = patch.clone();
5375 invalid.values = Some(Map::from_iter([
5376 ("id".into(), json!("bad-value")),
5377 ("project_id".into(), json!("p1")),
5378 ("title".into(), json!("title")),
5379 ("done".into(), json!({"bad":true})),
5380 ]));
5381 assert!(client
5382 .apply_outbox_ops([&invalid])
5383 .unwrap_err()
5384 .starts_with("sync.local_corrupt:"));
5385 client.conn.execute_batch("DROP TABLE tasks").unwrap();
5386 assert!(
5387 client.apply_outbox_ops([&patch]).is_err(),
5388 "SQL failure must not count as absence"
5389 );
5390 }
5391
5392 #[test]
5393 fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
5394 let mut schema = replay_schema(false);
5395 schema["tables"][0]["indexes"] =
5396 json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
5397 let mut client =
5398 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5399 let id = client
5400 .mutate(vec![Mutation::Upsert {
5401 table: "tasks".into(),
5402 values: Map::from_iter([
5403 ("id".into(), json!("queued")),
5404 ("project_id".into(), json!("p1")),
5405 ("title".into(), json!("occupied")),
5406 ("done".into(), json!(false)),
5407 ]),
5408 base_version: None,
5409 }])
5410 .unwrap();
5411 client
5412 .write_base_row(
5413 "tasks",
5414 &vec![
5415 Some(ColumnValue::String("server".into())),
5416 Some(ColumnValue::String("p1".into())),
5417 Some(ColumnValue::String("occupied".into())),
5418 Some(ColumnValue::Boolean(false)),
5419 ],
5420 1,
5421 )
5422 .unwrap();
5423 client.rebuild_overlay_if_dirty().unwrap();
5424 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5425 assert!(client
5426 .visible_row(client.schema.table("tasks").unwrap(), "queued")
5427 .unwrap()
5428 .is_none());
5429 assert!(client.rejections().is_empty());
5430 client.delete_base_row("tasks", "server").unwrap();
5431 client.rebuild_overlay_if_dirty().unwrap();
5432 assert!(client
5433 .visible_row(client.schema.table("tasks").unwrap(), "queued")
5434 .unwrap()
5435 .is_some());
5436 client
5437 .write_base_row(
5438 "tasks",
5439 &vec![
5440 Some(ColumnValue::String("base".into())),
5441 Some(ColumnValue::String("p1".into())),
5442 Some(ColumnValue::String("base-title".into())),
5443 Some(ColumnValue::Boolean(false)),
5444 ],
5445 1,
5446 )
5447 .unwrap();
5448 client.conn.execute_batch("DROP TABLE tasks").unwrap();
5449 client
5450 .retain_failed_commit(
5451 "retained",
5452 &[OutboxOp {
5453 table: "tasks".into(),
5454 row_id: "base".into(),
5455 upsert: true,
5456 base_version: Some(1),
5457 values: Some(Map::from_iter([
5458 ("id".into(), json!("base")),
5459 ("title".into(), json!("patch")),
5460 ])),
5461 }],
5462 )
5463 .expect("an existing base must not read the unavailable visible table");
5464 }
5465
5466 #[test]
5467 fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
5468 for failure in ["fts", "release"] {
5469 let mut client = SyncClient::new(
5470 "replay".into(),
5471 &replay_schema(true),
5472 ClientLimits::default(),
5473 )
5474 .unwrap();
5475 client
5476 .mutate(vec![Mutation::Upsert {
5477 table: "tasks".into(),
5478 values: Map::from_iter([
5479 ("id".into(), json!("queued")),
5480 ("project_id".into(), json!("p1")),
5481 ("title".into(), json!("needle")),
5482 ("done".into(), json!(false)),
5483 ]),
5484 base_version: None,
5485 }])
5486 .unwrap();
5487 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5488 let fts_before = client
5489 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
5490 .unwrap();
5491 let triggers_before: Vec<String> = client
5492 .conn
5493 .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
5494 .unwrap()
5495 .query_map([], |row| row.get(0))
5496 .unwrap()
5497 .collect::<Result<_, _>>()
5498 .unwrap();
5499 if failure == "fts" {
5500 client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
5501 } else {
5502 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();
5503 }
5504 client.overlay_dirty.set(true);
5505 assert!(client.rebuild_overlay().is_err());
5506 assert!(client.overlay_dirty.get());
5507 assert!(client.conn.is_autocommit());
5508 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5509 assert_eq!(
5510 client
5511 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
5512 .unwrap(),
5513 fts_before
5514 );
5515 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();
5516 assert_eq!(original_triggers, triggers_before);
5517 assert_eq!(client.pending_commit_ids().len(), 1);
5518 client
5519 .conn
5520 .execute_batch(if failure == "fts" {
5521 "DROP TRIGGER fail_fts"
5522 } else {
5523 "DROP TRIGGER fail_release"
5524 })
5525 .unwrap();
5526 client
5527 .rebuild_overlay()
5528 .expect("retry after removing the fault");
5529 }
5530 }
5531
5532 #[test]
5533 fn secondary_unique_collision_preserves_existing_synced_row() {
5534 let client = SyncClient::new(
5535 "unique-upsert-test".to_owned(),
5536 &json!({
5537 "version": 1,
5538 "tables": [{
5539 "name": "tasks",
5540 "primaryKey": "id",
5541 "columns": [
5542 { "name": "id", "type": "string", "nullable": false },
5543 { "name": "project_id", "type": "string", "nullable": false },
5544 { "name": "title", "type": "string", "nullable": false }
5545 ],
5546 "scopes": [{ "pattern": "project:{project_id}" }],
5547 "indexes": [{
5548 "name": "tasks_by_project_title",
5549 "columns": ["project_id", "title"],
5550 "unique": true
5551 }]
5552 }]
5553 }),
5554 ClientLimits::default(),
5555 )
5556 .expect("test client");
5557 let table = client.schema.table("tasks").expect("tasks table");
5558 let sql = client.insert_row_sql(&base_table("tasks"), table);
5559
5560 client
5561 .conn
5562 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
5563 .expect("insert first row");
5564 client
5565 .conn
5566 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
5567 .expect("update same primary key");
5568 client
5569 .conn
5570 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
5571 .expect("insert second row");
5572 assert!(client
5573 .conn
5574 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
5575 .is_err());
5576
5577 let rows = client
5578 .conn
5579 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
5580 .expect("prepare rows")
5581 .query_map([], |row| {
5582 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5583 })
5584 .expect("query rows")
5585 .collect::<Result<Vec<_>, _>>()
5586 .expect("collect rows");
5587 assert_eq!(
5588 rows,
5589 vec![
5590 ("t1".to_owned(), "updated".to_owned()),
5591 ("t2".to_owned(), "original".to_owned())
5592 ]
5593 );
5594 }
5595
5596 #[test]
5597 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
5598 let path = std::env::temp_dir().join(format!(
5599 "syncular-startup-intent-{}.db",
5600 uuid::Uuid::new_v4()
5601 ));
5602 let schema = json!({
5603 "version": 1,
5604 "tables": [{
5605 "name": "tasks",
5606 "primaryKey": "id",
5607 "columns": [
5608 { "name": "id", "type": "string", "nullable": false },
5609 { "name": "project_id", "type": "string", "nullable": false }
5610 ],
5611 "scopes": [{ "pattern": "project:{project_id}" }]
5612 }]
5613 });
5614
5615 {
5616 let mut first = SyncClient::open_path_with_identity(
5617 None,
5618 &schema,
5619 ClientLimits::default(),
5620 path.to_str().expect("UTF-8 temp path"),
5621 )
5622 .expect("first open");
5623 first
5624 .set_window(
5625 &WindowBase {
5626 table: "tasks".to_owned(),
5627 variable: "project_id".to_owned(),
5628 fixed_scopes: Vec::new(),
5629 params: None,
5630 },
5631 &["persisted".to_owned()],
5632 )
5633 .expect("persist window");
5634 }
5635
5636 let mut reopened = SyncClient::open_path_with_identity(
5637 None,
5638 &schema,
5639 ClientLimits::default(),
5640 path.to_str().expect("UTF-8 temp path"),
5641 )
5642 .expect("reopen");
5643 assert!(reopened.sync_needed());
5644 assert!(matches!(
5645 reopened.drain_sync_intents().as_slice(),
5646 [SyncIntent::Interactive]
5647 ));
5648 drop(reopened);
5649 std::fs::remove_file(path).expect("remove temp database");
5650 }
5651
5652 #[test]
5653 fn reopening_preserves_immutable_subscription_identity_and_progress() {
5654 let path = std::env::temp_dir().join(format!(
5655 "syncular-subscription-identity-{}.db",
5656 uuid::Uuid::new_v4()
5657 ));
5658 let schema = json!({
5659 "version": 1,
5660 "tables": [
5661 {
5662 "name": "tasks",
5663 "primaryKey": "id",
5664 "columns": [
5665 { "name": "id", "type": "string", "nullable": false },
5666 { "name": "project_id", "type": "string", "nullable": false }
5667 ],
5668 "scopes": [{ "pattern": "project:{project_id}" }]
5669 },
5670 {
5671 "name": "docs",
5672 "primaryKey": "id",
5673 "columns": [
5674 { "name": "id", "type": "string", "nullable": false },
5675 { "name": "org_id", "type": "string", "nullable": false },
5676 { "name": "project_id", "type": "string", "nullable": false }
5677 ],
5678 "scopes": [
5679 { "pattern": "org:{org_id}" },
5680 { "pattern": "project:{projectId}", "column": "project_id" }
5681 ]
5682 }
5683 ]
5684 });
5685
5686 {
5687 let mut first = SyncClient::open_path_with_identity(
5688 None,
5689 &schema,
5690 ClientLimits::default(),
5691 path.to_str().expect("UTF-8 temp path"),
5692 )
5693 .expect("first open");
5694 first
5695 .subscribe(
5696 "stable-subscription".to_owned(),
5697 "tasks".to_owned(),
5698 vec![(
5699 "project_id".to_owned(),
5700 vec!["p2".to_owned(), "p1".to_owned()],
5701 )],
5702 Some(r#"{"view":"v1"}"#.to_owned()),
5703 )
5704 .expect("persist subscription");
5705 let persisted = {
5706 let subscription = first
5707 .subs
5708 .iter_mut()
5709 .find(|subscription| subscription.id == "stable-subscription")
5710 .expect("subscription");
5711 subscription.cursor = 41;
5712 subscription.bootstrap_state = Some("resume-token".to_owned());
5713 subscription.effective = Some(vec![(
5714 "project_id".to_owned(),
5715 vec!["p1".to_owned(), "p2".to_owned()],
5716 )]);
5717 subscription.synced_once = true;
5718 subscription.clone()
5719 };
5720 first.persist_sub(&persisted).unwrap();
5721 }
5722
5723 let mut reopened = SyncClient::open_path_with_identity(
5724 None,
5725 &schema,
5726 ClientLimits::default(),
5727 path.to_str().expect("UTF-8 temp path"),
5728 )
5729 .expect("reopen");
5730 let progress = reopened
5731 .subscription_state("stable-subscription")
5732 .expect("persisted state");
5733 assert_eq!(progress.cursor, 41);
5734 assert!(progress.has_resume_token);
5735
5736 reopened
5737 .subscribe(
5738 "stable-subscription".to_owned(),
5739 "tasks".to_owned(),
5740 vec![(
5741 "project_id".to_owned(),
5742 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
5743 )],
5744 Some(r#"{"view":"v1"}"#.to_owned()),
5745 )
5746 .expect("canonical intent is idempotent");
5747 assert_eq!(
5748 reopened
5749 .subscription_state("stable-subscription")
5750 .expect("unchanged state")
5751 .cursor,
5752 progress.cursor
5753 );
5754
5755 for (table, scopes, params) in [
5756 (
5757 "tasks",
5758 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5759 Some(r#"{"view":"v1"}"#.to_owned()),
5760 ),
5761 (
5762 "tasks",
5763 vec![(
5764 "project_id".to_owned(),
5765 vec!["p2".to_owned(), "p1".to_owned()],
5766 )],
5767 Some(r#"{"view":"v2"}"#.to_owned()),
5768 ),
5769 (
5770 "docs",
5771 vec![
5772 ("org_id".to_owned(), vec!["o1".to_owned()]),
5773 ("projectId".to_owned(), vec!["p1".to_owned()]),
5774 ],
5775 Some(r#"{"view":"v1"}"#.to_owned()),
5776 ),
5777 ] {
5778 let error = reopened
5779 .subscribe(
5780 "stable-subscription".to_owned(),
5781 table.to_owned(),
5782 scopes,
5783 params,
5784 )
5785 .expect_err("identity rebind must fail");
5786 assert!(error.starts_with("client.subscription_intent_mismatch:"));
5787 assert_eq!(
5788 reopened
5789 .subscription_state("stable-subscription")
5790 .expect("unchanged state")
5791 .cursor,
5792 progress.cursor
5793 );
5794 }
5795
5796 drop(reopened);
5797 std::fs::remove_file(path).expect("remove temp database");
5798 }
5799
5800 #[test]
5801 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
5802 let path = std::env::temp_dir().join(format!(
5803 "syncular-satisfied-schema-floor-{}.db",
5804 uuid::Uuid::new_v4()
5805 ));
5806 let schema = json!({
5807 "version": 23,
5808 "tables": [{
5809 "name": "tasks",
5810 "primaryKey": "id",
5811 "columns": [
5812 { "name": "id", "type": "string", "nullable": false },
5813 { "name": "project_id", "type": "string", "nullable": false }
5814 ],
5815 "scopes": [{ "pattern": "project:{project_id}" }]
5816 }]
5817 });
5818
5819 {
5820 let mut first = SyncClient::open_path_with_identity(
5821 None,
5822 &schema,
5823 ClientLimits::default(),
5824 path.to_str().expect("UTF-8 temp path"),
5825 )
5826 .expect("first open");
5827 first
5828 .subscribe(
5829 "tasks".to_owned(),
5830 "tasks".to_owned(),
5831 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5832 None,
5833 )
5834 .expect("persist subscription");
5835 first.set_schema_floor(Some(SchemaFloor {
5836 required_schema_version: Some(22),
5837 latest_schema_version: Some(22),
5838 }));
5839 }
5840
5841 let mut reopened = SyncClient::open_path_with_identity(
5842 None,
5843 &schema,
5844 ClientLimits::default(),
5845 path.to_str().expect("UTF-8 temp path"),
5846 )
5847 .expect("reopen");
5848 assert!(reopened.schema_floor().is_none());
5849 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
5850 assert!(reopened.sync_needed());
5851 assert!(matches!(
5852 reopened.drain_sync_intents().as_slice(),
5853 [SyncIntent::Interactive]
5854 ));
5855 drop(reopened);
5856 std::fs::remove_file(path).expect("remove temp database");
5857 }
5858
5859 #[test]
5860 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
5861 let path = std::env::temp_dir().join(format!(
5862 "syncular-unsatisfied-schema-floor-{}.db",
5863 uuid::Uuid::new_v4()
5864 ));
5865 let schema = json!({
5866 "version": 1,
5867 "tables": [{
5868 "name": "tasks",
5869 "primaryKey": "id",
5870 "columns": [
5871 { "name": "id", "type": "string", "nullable": false },
5872 { "name": "project_id", "type": "string", "nullable": false }
5873 ],
5874 "scopes": [{ "pattern": "project:{project_id}" }]
5875 }]
5876 });
5877
5878 {
5879 let mut first = SyncClient::open_path_with_identity(
5880 None,
5881 &schema,
5882 ClientLimits::default(),
5883 path.to_str().expect("UTF-8 temp path"),
5884 )
5885 .expect("first open");
5886 first
5887 .subscribe(
5888 "tasks".to_owned(),
5889 "tasks".to_owned(),
5890 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5891 None,
5892 )
5893 .expect("persist subscription");
5894 first.set_schema_floor(Some(SchemaFloor {
5895 required_schema_version: Some(2),
5896 latest_schema_version: Some(2),
5897 }));
5898 }
5899
5900 let reopened = SyncClient::open_path_with_identity(
5901 None,
5902 &schema,
5903 ClientLimits::default(),
5904 path.to_str().expect("UTF-8 temp path"),
5905 )
5906 .expect("reopen");
5907 assert_eq!(
5908 reopened.schema_floor(),
5909 Some(&SchemaFloor {
5910 required_schema_version: Some(2),
5911 latest_schema_version: Some(2),
5912 })
5913 );
5914 assert!(!reopened.sync_needed());
5915 drop(reopened);
5916 std::fs::remove_file(path).expect("remove temp database");
5917 }
5918
5919 #[test]
5920 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
5921 let path = std::env::temp_dir().join(format!(
5922 "syncular-indexed-column-bump-{}.db",
5923 uuid::Uuid::new_v4()
5924 ));
5925 let old_schema = json!({
5926 "version": 1,
5927 "tables": [
5928 {
5929 "name": "tasks",
5930 "primaryKey": "id",
5931 "columns": [
5932 { "name": "id", "type": "string", "nullable": false },
5933 { "name": "project_id", "type": "string", "nullable": false }
5934 ],
5935 "scopes": [{ "pattern": "project:{project_id}" }]
5936 },
5937 {
5938 "name": "legacy",
5939 "primaryKey": "id",
5940 "columns": [
5941 { "name": "id", "type": "string", "nullable": false },
5942 { "name": "project_id", "type": "string", "nullable": false }
5943 ],
5944 "scopes": [{ "pattern": "project:{project_id}" }]
5945 }
5946 ]
5947 });
5948 {
5949 let mut first = SyncClient::open_path_with_identity(
5950 None,
5951 &old_schema,
5952 ClientLimits::default(),
5953 path.to_str().expect("UTF-8 temp path"),
5954 )
5955 .expect("open old schema");
5956 first
5957 .subscribe(
5958 "legacy-sub".to_owned(),
5959 "legacy".to_owned(),
5960 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5961 None,
5962 )
5963 .expect("persist legacy subscription");
5964 }
5965
5966 let new_schema = json!({
5967 "version": 2,
5968 "tables": [{
5969 "name": "tasks",
5970 "primaryKey": "id",
5971 "columns": [
5972 { "name": "id", "type": "string", "nullable": false },
5973 { "name": "project_id", "type": "string", "nullable": false },
5974 { "name": "facility_membership_id", "type": "string", "nullable": true }
5975 ],
5976 "scopes": [{ "pattern": "project:{project_id}" }],
5977 "indexes": [{
5978 "name": "tasks_by_membership",
5979 "columns": ["project_id", "facility_membership_id"],
5980 "unique": false
5981 }]
5982 }]
5983 });
5984 let upgraded = SyncClient::open_path_with_identity(
5985 None,
5986 &new_schema,
5987 ClientLimits::default(),
5988 path.to_str().expect("UTF-8 temp path"),
5989 )
5990 .expect("open upgraded schema");
5991 let columns = upgraded
5992 .conn
5993 .prepare("PRAGMA table_info(tasks)")
5994 .expect("prepare columns")
5995 .query_map([], |row| row.get::<_, String>(1))
5996 .expect("query columns")
5997 .collect::<Result<Vec<_>, _>>()
5998 .expect("collect columns");
5999 assert!(columns.contains(&"facility_membership_id".to_owned()));
6000 let index_count: i64 = upgraded
6001 .conn
6002 .query_row(
6003 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
6004 [],
6005 |row| row.get(0),
6006 )
6007 .expect("query index");
6008 assert_eq!(index_count, 1);
6009 assert!(upgraded.subscription_state("legacy-sub").is_none());
6010 drop(upgraded);
6011 std::fs::remove_file(path).expect("remove temp database");
6012 }
6013
6014 #[test]
6015 fn migrates_pre_envelope_outcome_journal_additively() {
6016 let path = std::env::temp_dir().join(format!(
6017 "syncular-outcome-migration-{}.db",
6018 uuid::Uuid::new_v4()
6019 ));
6020 let conn = Connection::open(&path).expect("open legacy database");
6021 conn.execute_batch(
6022 "CREATE TABLE _syncular_commit_outcomes (
6023 seq INTEGER PRIMARY KEY AUTOINCREMENT,
6024 client_commit_id TEXT NOT NULL UNIQUE,
6025 status TEXT NOT NULL,
6026 recorded_at_ms INTEGER NOT NULL,
6027 results_json TEXT NOT NULL,
6028 resolution TEXT NOT NULL DEFAULT 'active',
6029 resolved_at_ms INTEGER,
6030 replacement_client_commit_id TEXT);",
6031 )
6032 .expect("create legacy outcome journal");
6033 drop(conn);
6034 let schema = json!({
6035 "version": 1,
6036 "tables": [{
6037 "name": "tasks",
6038 "primaryKey": "id",
6039 "columns": [
6040 { "name": "id", "type": "string", "nullable": false },
6041 { "name": "project_id", "type": "string", "nullable": false }
6042 ],
6043 "scopes": [{ "pattern": "project:{project_id}" }]
6044 }]
6045 });
6046 let client = SyncClient::open_path(
6047 "migration-native".to_owned(),
6048 &schema,
6049 ClientLimits::default(),
6050 path.to_str().expect("UTF-8 temp path"),
6051 )
6052 .expect("migrate database");
6053 let has_operations = client
6054 .conn
6055 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
6056 .expect("prepare table info")
6057 .query_map([], |row| row.get::<_, String>(1))
6058 .expect("query table info")
6059 .filter_map(Result::ok)
6060 .any(|column| column == "operations_json");
6061 assert!(has_operations);
6062 drop(client);
6063 std::fs::remove_file(path).expect("remove temp database");
6064 }
6065
6066 #[test]
6067 fn retained_rejection_survives_file_reopen_and_security_purge() {
6068 let path =
6069 std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
6070 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
6071 { "name": "id", "type": "string", "nullable": false },
6072 { "name": "project_id", "type": "string", "nullable": false },
6073 { "name": "title", "type": "string", "nullable": false }
6074 ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
6075 {
6076 let mut first = SyncClient::open_path(
6077 "retained-native".to_owned(),
6078 &schema,
6079 ClientLimits::default(),
6080 path.to_str().unwrap(),
6081 )
6082 .unwrap();
6083 first
6084 .write_base_row(
6085 "tasks",
6086 &vec![
6087 Some(ColumnValue::String("t1".into())),
6088 Some(ColumnValue::String("p1".into())),
6089 Some(ColumnValue::String("server".into())),
6090 ],
6091 1,
6092 )
6093 .unwrap();
6094 first.rebuild_overlay().expect("rebuild overlay");
6095 let operation = OutboxOp {
6096 table: "tasks".into(),
6097 row_id: "t1".into(),
6098 upsert: true,
6099 base_version: Some(1),
6100 values: Some(Map::from_iter([
6101 ("id".into(), json!("t1")),
6102 ("title".into(), json!("mine")),
6103 ])),
6104 };
6105 first
6106 .retain_failed_commit("retained", std::slice::from_ref(&operation))
6107 .unwrap();
6108 first
6109 .persist_commit_outcome(
6110 "retained",
6111 CommitOutcomeStatus::Rejected,
6112 &[CommitOperationOutcome::Error {
6113 rejection: RejectionRecord {
6114 client_commit_id: "retained".into(),
6115 op_index: 0,
6116 code: "validation.denied".into(),
6117 message: "denied".into(),
6118 retryable: false,
6119 details: None,
6120 operation: None,
6121 },
6122 }],
6123 Some(&[operation]),
6124 )
6125 .unwrap();
6126 first.rebuild_overlay().expect("rebuild overlay");
6127 }
6128 {
6129 let mut reopened = SyncClient::open_path(
6130 "retained-native".to_owned(),
6131 &schema,
6132 ClientLimits::default(),
6133 path.to_str().unwrap(),
6134 )
6135 .unwrap();
6136 let row = reopened
6137 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6138 .expect("read visible row")
6139 .unwrap();
6140 assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
6141 assert_eq!(
6142 reopened
6143 .commit_outcome("retained")
6144 .unwrap()
6145 .unwrap()
6146 .retained_rows
6147 .unwrap()[0]
6148 .server_row
6149 .as_ref()
6150 .unwrap()["title"],
6151 json!("server")
6152 );
6153 reopened
6154 .conn
6155 .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
6156 .unwrap();
6157 reopened.rebuild_overlay().expect("rebuild overlay");
6158 assert!(reopened
6159 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6160 .expect("read visible row")
6161 .is_none());
6162 let retained = reopened.commit_outcome("retained").unwrap().unwrap();
6163 assert!(retained.retained_rows.is_some());
6164 assert_eq!(
6165 retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
6166 json!("mine")
6167 );
6168 reopened
6169 .purge_local_data(&LocalDataPurgeInput {
6170 purge_id: "protected-purge".into(),
6171 targets: vec![LocalDataPurgeTarget {
6172 table: "tasks".into(),
6173 selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
6174 }],
6175 })
6176 .unwrap();
6177 assert!(reopened
6178 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6179 .expect("read visible row")
6180 .is_none());
6181 assert!(reopened
6182 .commit_outcome("retained")
6183 .unwrap()
6184 .unwrap()
6185 .retained_rows
6186 .is_none());
6187 }
6188 std::fs::remove_file(path).unwrap();
6189 }
6190
6191 #[test]
6192 fn durable_conflict_outcome_and_resolution_survive_reopen() {
6193 let path = std::env::temp_dir().join(format!(
6194 "syncular-durable-outcome-{}.db",
6195 uuid::Uuid::new_v4()
6196 ));
6197 let schema = json!({
6198 "version": 1,
6199 "tables": [{
6200 "name": "tasks",
6201 "primaryKey": "id",
6202 "columns": [
6203 { "name": "id", "type": "string", "nullable": false },
6204 { "name": "project_id", "type": "string", "nullable": false }
6205 ],
6206 "scopes": [{ "pattern": "project:{project_id}" }]
6207 }]
6208 });
6209 let conflict = ConflictRecord {
6210 client_commit_id: "losing-commit".to_owned(),
6211 op_index: 0,
6212 table: "tasks".to_owned(),
6213 row_id: "t1".to_owned(),
6214 code: "sync.version_conflict".to_owned(),
6215 message: "stale base version".to_owned(),
6216 server_version: 2,
6217 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6218 conflict_columns: vec!["project_id".to_owned()],
6219 operation: Some(CommitOperation {
6220 table: "tasks".to_owned(),
6221 row_id: "t1".to_owned(),
6222 op: "upsert".to_owned(),
6223 base_version: Some(1),
6224 values: Some(Map::from_iter([
6225 ("id".into(), json!("t1")),
6226 ("project_id".into(), json!("p1")),
6227 ])),
6228 }),
6229 };
6230 let failed_operations = vec![
6231 OutboxOp {
6232 upsert: true,
6233 table: "tasks".to_owned(),
6234 row_id: "t1".to_owned(),
6235 base_version: Some(1),
6236 values: Some(Map::from_iter([
6237 ("id".into(), json!("t1")),
6238 ("project_id".into(), json!("p1")),
6239 ])),
6240 },
6241 OutboxOp {
6242 upsert: true,
6243 table: "tasks".to_owned(),
6244 row_id: "status-event-1".to_owned(),
6245 base_version: Some(0),
6246 values: Some(Map::from_iter([
6247 ("id".into(), json!("status-event-1")),
6248 ("project_id".into(), json!("p1")),
6249 ])),
6250 },
6251 ];
6252
6253 {
6254 let mut first = SyncClient::open_path(
6255 "durable-native".to_owned(),
6256 &schema,
6257 ClientLimits::default(),
6258 path.to_str().expect("UTF-8 temp path"),
6259 )
6260 .expect("first open");
6261 first
6262 .begin_observation("test_outcome")
6263 .expect("begin outcome");
6264 first
6265 .persist_commit_outcome(
6266 "losing-commit",
6267 CommitOutcomeStatus::Conflict,
6268 &[CommitOperationOutcome::Conflict {
6269 conflict: conflict.clone(),
6270 }],
6271 Some(&failed_operations),
6272 )
6273 .expect("persist outcome");
6274 first.conflicts.push(conflict);
6275 first
6276 .finish_observation(
6277 "test_outcome",
6278 ChangeAccumulator {
6279 conflicts: true,
6280 outcomes: true,
6281 ..ChangeAccumulator::default()
6282 },
6283 )
6284 .expect("commit outcome");
6285 }
6286
6287 {
6288 let mut reopened = SyncClient::open_path(
6289 "durable-native".to_owned(),
6290 &schema,
6291 ClientLimits::default(),
6292 path.to_str().expect("UTF-8 temp path"),
6293 )
6294 .expect("reopen");
6295 assert_eq!(reopened.conflicts().len(), 1);
6296 assert_eq!(
6297 reopened.conflicts()[0].conflict_columns,
6298 vec!["project_id".to_owned()],
6299 "conflictColumns survives the journal round trip"
6300 );
6301 let outcome = reopened
6302 .commit_outcome("losing-commit")
6303 .expect("read outcome")
6304 .expect("outcome");
6305 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
6306 let operations = outcome.operations.expect("aggregate envelope");
6307 assert_eq!(operations.len(), 2);
6308 assert_eq!(operations[1].row_id, "status-event-1");
6309 let resolved = reopened
6310 .resolve_commit_outcome(ResolveCommitOutcomeInput {
6311 client_commit_id: "losing-commit".to_owned(),
6312 resolution: CommitOutcomeResolution::ResolvedKeepServer,
6313 replacement_client_commit_id: None,
6314 })
6315 .expect("resolve");
6316 assert_eq!(
6317 resolved.resolution,
6318 CommitOutcomeResolution::ResolvedKeepServer
6319 );
6320 assert!(reopened.conflicts().is_empty());
6321 }
6322
6323 let reopened = SyncClient::open_path(
6324 "durable-native".to_owned(),
6325 &schema,
6326 ClientLimits::default(),
6327 path.to_str().expect("UTF-8 temp path"),
6328 )
6329 .expect("second reopen");
6330 assert!(reopened.conflicts().is_empty());
6331 assert_eq!(
6332 reopened
6333 .commit_outcome("losing-commit")
6334 .expect("read outcome")
6335 .expect("outcome")
6336 .resolution,
6337 CommitOutcomeResolution::ResolvedKeepServer
6338 );
6339 drop(reopened);
6340 std::fs::remove_file(path).expect("remove temp database");
6341 }
6342
6343 #[test]
6344 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
6345 let path =
6346 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6347 let schema = json!({
6348 "version": 1,
6349 "tables": [{
6350 "name": "tasks",
6351 "primaryKey": "id",
6352 "columns": [
6353 { "name": "id", "type": "string", "nullable": false },
6354 { "name": "project_id", "type": "string", "nullable": false }
6355 ],
6356 "scopes": [{ "pattern": "project:{project_id}" }]
6357 }]
6358 });
6359 let mut client = SyncClient::open_path_with_identity(
6360 Some("sidecar-client".to_owned()),
6361 &schema,
6362 ClientLimits::default(),
6363 path.to_str().expect("UTF-8 temp path"),
6364 )
6365 .expect("open owner");
6366 let base = WindowBase {
6367 table: "tasks".to_owned(),
6368 variable: "project_id".to_owned(),
6369 fixed_scopes: Vec::new(),
6370 params: None,
6371 };
6372 client
6373 .set_window(&base, &["one".to_owned()])
6374 .expect("set window");
6375 client
6376 .mutate(vec![Mutation::Upsert {
6377 table: "tasks".to_owned(),
6378 values: Map::from_iter([
6379 ("id".to_owned(), Value::from("t1")),
6380 ("project_id".to_owned(), Value::from("one")),
6381 ]),
6382 base_version: None,
6383 }])
6384 .expect("local mutate");
6385
6386 let coverage = [WindowCoverage {
6387 base,
6388 units: vec!["one".to_owned(), "missing".to_owned()],
6389 }];
6390 let owner = client
6391 .query_snapshot(
6392 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6393 &[],
6394 &coverage,
6395 None,
6396 )
6397 .expect("owner snapshot");
6398 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6399 let sidecar = reader
6400 .query_snapshot(
6401 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6402 &[],
6403 &coverage,
6404 )
6405 .expect("sidecar snapshot");
6406
6407 assert_eq!(sidecar.revision, owner.revision);
6408 assert_eq!(sidecar.rows, owner.rows);
6409 assert_eq!(
6410 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
6411 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
6412 );
6413 assert_eq!(sidecar.revision, "2");
6414 assert_eq!(sidecar.rows[0]["id"], "t1");
6415 assert_eq!(sidecar.rows[0]["server_version"], -1);
6416 assert!(!sidecar.coverage.complete);
6417 assert_eq!(sidecar.coverage.pending.len(), 1);
6418 assert_eq!(sidecar.coverage.missing.len(), 1);
6419
6420 drop(reader);
6421 drop(client);
6422 std::fs::remove_file(path).expect("remove temp database");
6423 }
6424
6425 #[test]
6426 fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
6427 let path =
6428 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6429 let schema = json!({
6430 "version": 1,
6431 "tables": [{
6432 "name": "tasks",
6433 "primaryKey": "id",
6434 "columns": [
6435 { "name": "id", "type": "string", "nullable": false },
6436 { "name": "project_id", "type": "string", "nullable": false }
6437 ],
6438 "scopes": [{ "pattern": "project:{project_id}" }]
6439 }]
6440 });
6441 let mut client = SyncClient::open_path_with_identity(
6442 Some("sidecar-catchup".to_owned()),
6443 &schema,
6444 ClientLimits::default(),
6445 path.to_str().expect("UTF-8 temp path"),
6446 )
6447 .expect("open owner");
6448 client
6449 .subscribe(
6450 "s1".into(),
6451 "tasks".into(),
6452 vec![("project_id".into(), vec!["one".into()])],
6453 None,
6454 )
6455 .expect("subscribe");
6456 let commit_id = client
6457 .mutate(vec![Mutation::Upsert {
6458 table: "tasks".to_owned(),
6459 values: Map::from_iter([
6460 ("id".to_owned(), Value::from("t1")),
6461 ("project_id".to_owned(), Value::from("one")),
6462 ]),
6463 base_version: None,
6464 }])
6465 .expect("mutate");
6466 let revision = client.local_revision().to_string();
6467
6468 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6469 assert_eq!(reader.local_revision().expect("revision"), revision);
6470
6471 let read = reader
6472 .snapshot_read(&SnapshotReadRequest {
6473 statements: vec![
6474 SnapshotStatement {
6475 sql: "SELECT 1 AS n",
6476 params: &[],
6477 },
6478 SnapshotStatement {
6479 sql: "SELECT id FROM tasks ORDER BY id",
6480 params: &[],
6481 },
6482 ],
6483 coverage: &[],
6484 subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
6485 commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
6486 })
6487 .expect("snapshot read");
6488 assert_eq!(read.revision, revision);
6489 assert_eq!(read.queries.len(), 2);
6490 assert_eq!(read.queries[0][0]["n"], 1);
6491 assert_eq!(read.queries[1][0]["id"], "t1");
6492 match &read.subscriptions[0] {
6493 SubscriptionCatchup::Known {
6494 status,
6495 cursor,
6496 bootstrap_complete,
6497 known_pending_pages,
6498 ..
6499 } => {
6500 assert_eq!(status, "active");
6501 assert_eq!(*cursor, -1);
6502 assert!(!bootstrap_complete);
6503 assert!(known_pending_pages);
6504 }
6505 other => panic!("expected a known subscription: {other:?}"),
6506 }
6507 assert!(matches!(
6508 &read.subscriptions[1],
6509 SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
6510 ));
6511 assert!(matches!(
6512 &read.deliveries[0],
6513 CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
6514 ));
6515 assert!(matches!(
6516 &read.deliveries[1],
6517 CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
6518 ));
6519
6520 assert!(reader
6522 .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
6523 .is_err());
6524 assert_eq!(
6525 reader.local_revision().expect("revision after rollback"),
6526 revision
6527 );
6528 assert!(reader
6530 .query_snapshot("DELETE FROM tasks", &[], &[])
6531 .is_err());
6532
6533 client
6535 .conn
6536 .execute(
6537 "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
6538 [],
6539 )
6540 .expect("corrupt revision");
6541 let failure = reader.local_revision().expect_err("corrupt revision fails");
6542 assert_eq!(failure.code, Some("sync.local_corrupt"));
6543
6544 drop(reader);
6545 drop(client);
6546 std::fs::remove_file(path).expect("remove temp database");
6547 }
6548
6549 #[test]
6550 fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
6551 let path =
6552 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6553 let schema = json!({
6554 "version": 1,
6555 "tables": [{
6556 "name": "tasks",
6557 "primaryKey": "id",
6558 "columns": [
6559 { "name": "id", "type": "string", "nullable": false },
6560 { "name": "project_id", "type": "string", "nullable": false }
6561 ],
6562 "scopes": [{ "pattern": "project:{project_id}" }]
6563 }]
6564 });
6565 let mut client = SyncClient::open_path_with_identity(
6566 Some("sidecar-delivery".to_owned()),
6567 &schema,
6568 ClientLimits::default(),
6569 path.to_str().expect("UTF-8 temp path"),
6570 )
6571 .expect("open owner");
6572 client.begin_observation("test_delivery").expect("observe");
6573 let operations = [OutboxOp {
6574 upsert: true,
6575 table: "tasks".to_owned(),
6576 row_id: "t1".to_owned(),
6577 base_version: Some(1),
6578 values: Some(Map::from_iter([
6579 ("id".into(), json!("t1")),
6580 ("project_id".into(), json!("p1")),
6581 ])),
6582 }];
6583 client
6584 .persist_commit_outcome(
6585 "conflict-commit",
6586 CommitOutcomeStatus::Conflict,
6587 &[CommitOperationOutcome::Conflict {
6588 conflict: ConflictRecord {
6589 client_commit_id: "conflict-commit".to_owned(),
6590 op_index: 0,
6591 table: "tasks".to_owned(),
6592 row_id: "t1".to_owned(),
6593 code: "sync.version_conflict".to_owned(),
6594 message: "stale base version".to_owned(),
6595 server_version: 2,
6596 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6597 conflict_columns: vec!["project_id".to_owned()],
6598 operation: None,
6599 },
6600 }],
6601 Some(&operations),
6602 )
6603 .expect("persist conflict");
6604 client
6605 .persist_commit_outcome(
6606 "rejected-commit",
6607 CommitOutcomeStatus::Rejected,
6608 &[CommitOperationOutcome::Error {
6609 rejection: RejectionRecord {
6610 client_commit_id: "rejected-commit".to_owned(),
6611 op_index: 0,
6612 code: "sync.unknown_table".to_owned(),
6613 message: "unknown table".to_owned(),
6614 retryable: false,
6615 details: None,
6616 operation: None,
6617 },
6618 }],
6619 Some(&operations),
6620 )
6621 .expect("persist rejection");
6622 client
6623 .finish_observation(
6624 "test_delivery",
6625 ChangeAccumulator {
6626 conflicts: true,
6627 outcomes: true,
6628 ..ChangeAccumulator::default()
6629 },
6630 )
6631 .expect("commit outcomes");
6632
6633 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6634 match reader.commit_delivery("conflict-commit").expect("conflict") {
6635 CommitDelivery::Known { outcome, .. } => {
6636 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
6637 assert!(matches!(
6638 outcome.results.as_slice(),
6639 [CommitOperationOutcome::Conflict { conflict }]
6640 if conflict.code == "sync.version_conflict"
6641 && conflict.server_version == 2
6642 ));
6643 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
6644 assert!(
6645 outcome.retained_rows.is_none(),
6646 "the sidecar reports the persisted view, not owner-derived retained rows"
6647 );
6648 }
6649 other => panic!("expected a known conflict: {other:?}"),
6650 }
6651 match reader
6652 .commit_delivery("rejected-commit")
6653 .expect("rejection")
6654 {
6655 CommitDelivery::Known { outcome, .. } => {
6656 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
6657 assert!(matches!(
6658 outcome.results.as_slice(),
6659 [CommitOperationOutcome::Error { rejection }]
6660 if rejection.code == "sync.unknown_table"
6661 ));
6662 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
6663 assert!(outcome.retained_rows.is_none());
6664 }
6665 other => panic!("expected a known rejection: {other:?}"),
6666 }
6667 drop(reader);
6668 drop(client);
6669 std::fs::remove_file(path).expect("remove temp database");
6670 }
6671
6672 #[test]
6673 fn snapshot_read_isolates_a_concurrent_writer_commit() {
6674 use std::sync::mpsc::channel;
6675 let path = std::env::temp_dir().join(format!(
6676 "syncular-read-concurrent-{}.db",
6677 uuid::Uuid::new_v4()
6678 ));
6679 let schema = json!({
6680 "version": 1,
6681 "tables": [{
6682 "name": "tasks",
6683 "primaryKey": "id",
6684 "columns": [
6685 { "name": "id", "type": "string", "nullable": false },
6686 { "name": "project_id", "type": "string", "nullable": false }
6687 ],
6688 "scopes": [{ "pattern": "project:{project_id}" }]
6689 }]
6690 });
6691 let mut client = SyncClient::open_path_with_identity(
6692 Some("sidecar-concurrent".to_owned()),
6693 &schema,
6694 ClientLimits::default(),
6695 path.to_str().expect("UTF-8 temp path"),
6696 )
6697 .expect("open owner");
6698 client
6699 .mutate(vec![Mutation::Upsert {
6700 table: "tasks".to_owned(),
6701 values: Map::from_iter([
6702 ("id".to_owned(), Value::from("t1")),
6703 ("project_id".to_owned(), Value::from("one")),
6704 ]),
6705 base_version: None,
6706 }])
6707 .expect("seed row");
6708 let revision_before = client.local_revision().to_string();
6709
6710 let (ready_tx, ready_rx) = channel::<()>();
6713 let (done_tx, done_rx) = channel::<()>();
6714 let writer_path = path.to_string_lossy().into_owned();
6715 let writer_schema = schema.clone();
6716 let writer = std::thread::spawn(move || {
6717 let mut writer = SyncClient::open_path_with_identity(
6718 Some("sidecar-concurrent".to_owned()),
6719 &writer_schema,
6720 ClientLimits::default(),
6721 &writer_path,
6722 )
6723 .expect("open writer");
6724 ready_rx.recv().expect("reader signalled");
6725 writer
6726 .mutate(vec![Mutation::Upsert {
6727 table: "tasks".to_owned(),
6728 values: Map::from_iter([
6729 ("id".to_owned(), Value::from("t2")),
6730 ("project_id".to_owned(), Value::from("one")),
6731 ]),
6732 base_version: None,
6733 }])
6734 .expect("writer commit");
6735 let _ = done_tx.send(());
6736 });
6737 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6738 SNAPSHOT_READ_HOOK.with(|slot| {
6739 *slot.borrow_mut() = Some(Box::new(move || {
6740 ready_tx.send(()).expect("signal writer");
6741 done_rx.recv().expect("writer finished");
6742 }));
6743 });
6744 let read = reader
6745 .snapshot_read(&SnapshotReadRequest {
6746 statements: vec![
6747 SnapshotStatement {
6748 sql: "SELECT count(*) AS n FROM tasks",
6749 params: &[],
6750 },
6751 SnapshotStatement {
6752 sql: "SELECT count(*) AS n FROM tasks",
6753 params: &[],
6754 },
6755 ],
6756 coverage: &[],
6757 subscriptions: Vec::new(),
6758 commit_ids: Vec::new(),
6759 })
6760 .expect("snapshot read");
6761 assert_eq!(
6762 read.queries[0][0]["n"], 1,
6763 "first statement sees the pre-commit row"
6764 );
6765 assert_eq!(
6766 read.queries[1][0]["n"], 1,
6767 "a later statement keeps the one read snapshot"
6768 );
6769 assert_eq!(
6770 read.revision, revision_before,
6771 "the read reports the snapshot revision, not the writer's"
6772 );
6773 writer.join().expect("join writer");
6774
6775 assert_ne!(
6776 reader.local_revision().expect("revision after commit"),
6777 revision_before,
6778 "the next read observes the committed revision"
6779 );
6780 let after = reader
6781 .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
6782 .expect("count after commit");
6783 assert_eq!(
6784 after.rows[0]["n"], 2,
6785 "the committed row is visible afterwards"
6786 );
6787
6788 drop(reader);
6789 drop(client);
6790 std::fs::remove_file(path).expect("remove temp database");
6791 }
6792
6793 #[test]
6794 fn snapshot_read_rejects_every_non_canonical_revision() {
6795 let path = std::env::temp_dir().join(format!(
6796 "syncular-read-revision-{}.db",
6797 uuid::Uuid::new_v4()
6798 ));
6799 let schema = json!({
6800 "version": 1,
6801 "tables": [{
6802 "name": "tasks",
6803 "primaryKey": "id",
6804 "columns": [
6805 { "name": "id", "type": "string", "nullable": false },
6806 { "name": "project_id", "type": "string", "nullable": false }
6807 ],
6808 "scopes": [{ "pattern": "project:{project_id}" }]
6809 }]
6810 });
6811 let client = SyncClient::open_path_with_identity(
6812 Some("sidecar-revision".to_owned()),
6813 &schema,
6814 ClientLimits::default(),
6815 path.to_str().expect("UTF-8 temp path"),
6816 )
6817 .expect("open owner");
6818 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6819 for bad in [
6820 "+1",
6821 "01",
6822 "-1",
6823 " 1",
6824 "1 ",
6825 "18446744073709551616",
6826 "1.0",
6827 "abc",
6828 "",
6829 ] {
6830 client
6831 .conn
6832 .execute(
6833 "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
6834 rusqlite::params![bad],
6835 )
6836 .expect("corrupt revision");
6837 let failure = reader.local_revision().expect_err("corrupt marker fails");
6838 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
6839 }
6840 for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
6841 client
6842 .conn
6843 .execute_batch(&format!(
6844 "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
6845 ))
6846 .expect("corrupt stored revision type");
6847 assert_eq!(
6848 reader.local_revision().expect_err("invalid SQL field").code,
6849 Some("sync.local_corrupt")
6850 );
6851 }
6852 client
6854 .conn
6855 .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
6856 .expect("delete marker");
6857 assert_eq!(reader.local_revision().expect("legacy zero"), "0");
6858
6859 drop(reader);
6860 drop(client);
6861 std::fs::remove_file(path).expect("remove temp database");
6862 }
6863
6864 #[test]
6865 fn snapshot_read_rejects_corrupt_subscription_state() {
6866 let path = std::env::temp_dir().join(format!(
6867 "syncular-read-substate-{}.db",
6868 uuid::Uuid::new_v4()
6869 ));
6870 let schema = json!({
6871 "version": 1,
6872 "tables": [{
6873 "name": "tasks",
6874 "primaryKey": "id",
6875 "columns": [
6876 { "name": "id", "type": "string", "nullable": false },
6877 { "name": "project_id", "type": "string", "nullable": false }
6878 ],
6879 "scopes": [{ "pattern": "project:{project_id}" }]
6880 }]
6881 });
6882 let mut client = SyncClient::open_path_with_identity(
6883 Some("sidecar-substate".to_owned()),
6884 &schema,
6885 ClientLimits::default(),
6886 path.to_str().expect("UTF-8 temp path"),
6887 )
6888 .expect("open owner");
6889 client
6890 .subscribe(
6891 "s1".into(),
6892 "tasks".into(),
6893 vec![("project_id".into(), vec!["one".into()])],
6894 None,
6895 )
6896 .expect("subscribe");
6897 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6898 for bad in [
6899 "not json".to_owned(),
6900 "{}".to_owned(),
6901 json!({ "status": "bogus", "cursor": 0 }).to_string(),
6902 json!({ "status": "active", "cursor": "x" }).to_string(),
6903 json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
6904 json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
6905 ] {
6906 client
6907 .conn
6908 .execute(
6909 "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
6910 rusqlite::params![bad],
6911 )
6912 .expect("corrupt subscription state");
6913 let failure = reader
6914 .subscription_catchup("s1")
6915 .expect_err("corrupt state fails");
6916 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
6917 }
6918
6919 for assignment in [
6920 "tbl=X'7461736b73'",
6921 "state_json=X'7b7d'",
6922 "state_json=CAST(X'FF' AS TEXT)",
6923 ] {
6924 client.conn.execute_batch(&format!(
6925 "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
6926 )).expect("corrupt stored subscription type");
6927 assert_eq!(
6928 reader
6929 .subscription_catchup("s1")
6930 .expect_err("invalid SQL field")
6931 .code,
6932 Some("sync.local_corrupt")
6933 );
6934 }
6935
6936 drop(reader);
6937 drop(client);
6938 std::fs::remove_file(path).expect("remove temp database");
6939 }
6940
6941 #[test]
6942 fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
6943 let path = std::env::temp_dir().join(format!(
6944 "syncular-read-rollback-{}.db",
6945 uuid::Uuid::new_v4()
6946 ));
6947 let schema = json!({
6948 "version": 1,
6949 "tables": [{
6950 "name": "tasks",
6951 "primaryKey": "id",
6952 "columns": [
6953 { "name": "id", "type": "string", "nullable": false },
6954 { "name": "project_id", "type": "string", "nullable": false }
6955 ],
6956 "scopes": [{ "pattern": "project:{project_id}" }]
6957 }]
6958 });
6959 let mut client = SyncClient::open_path_with_identity(
6960 Some("sidecar-rollback".to_owned()),
6961 &schema,
6962 ClientLimits::default(),
6963 path.to_str().expect("UTF-8 temp path"),
6964 )
6965 .expect("open owner");
6966 let commit_id = client
6967 .mutate(vec![Mutation::Upsert {
6968 table: "tasks".to_owned(),
6969 values: Map::from_iter([
6970 ("id".to_owned(), Value::from("t1")),
6971 ("project_id".to_owned(), Value::from("one")),
6972 ]),
6973 base_version: None,
6974 }])
6975 .expect("seed row");
6976 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6977 let revision = reader.local_revision().expect("revision");
6978
6979 let failure = reader
6982 .snapshot_read(&SnapshotReadRequest {
6983 statements: vec![
6984 SnapshotStatement {
6985 sql: "SELECT id FROM tasks",
6986 params: &[],
6987 },
6988 SnapshotStatement {
6989 sql: "SELECT * FROM does_not_exist",
6990 params: &[],
6991 },
6992 ],
6993 coverage: &[],
6994 subscriptions: Vec::new(),
6995 commit_ids: vec![commit_id.clone()],
6996 })
6997 .expect_err("second statement fails");
6998 assert_eq!(failure.code, None);
6999 assert_eq!(
7000 reader.local_revision().expect("revision after rollback"),
7001 revision,
7002 "a rolled-back read consumes no revision"
7003 );
7004 assert!(
7005 !matches!(
7006 reader
7007 .commit_delivery(&commit_id)
7008 .expect("delivery after rollback"),
7009 CommitDelivery::Unknown { .. }
7010 ),
7011 "the same reader serves the next read after releasing the connection"
7012 );
7013
7014 let guard = reader
7016 .snapshot_read(&SnapshotReadRequest {
7017 statements: vec![SnapshotStatement {
7018 sql: "DELETE FROM tasks",
7019 params: &[],
7020 }],
7021 coverage: &[],
7022 subscriptions: Vec::new(),
7023 commit_ids: Vec::new(),
7024 })
7025 .expect_err("write guard");
7026 assert!(guard.message.contains("read-only"), "{}", guard.message);
7027
7028 drop(reader);
7029 drop(client);
7030 std::fs::remove_file(path).expect("remove temp database");
7031 }
7032
7033 #[test]
7034 fn owned_query_failures_classify_order_bound_and_clear() {
7035 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7036 rusqlite::ffi::Error::new(266),
7037 Some("private SQLite prose".to_owned()),
7038 ));
7039 assert_eq!(io.code, Some("client.storage_io"));
7040 assert_eq!(io.sqlite_code, Some(266));
7041 assert_eq!(io.message, "local SQLite storage I/O failed");
7042 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7043 rusqlite::ffi::Error::new(26),
7044 None,
7045 ));
7046 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
7047 assert!(!io.retryable());
7048 assert!(!corrupt.retryable());
7049
7050 for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
7053 let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7054 rusqlite::ffi::Error::new(raw),
7055 Some("database is locked".to_owned()),
7056 ));
7057 assert_eq!(busy.code, Some("client.storage_busy"));
7058 assert_eq!(busy.sqlite_code, Some(extended));
7059 assert_eq!(busy.message, "local SQLite storage is busy");
7060 assert!(busy.retryable());
7061 }
7062 let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7063 rusqlite::ffi::Error::new(1),
7064 Some("generic SQLite prose".to_owned()),
7065 ));
7066 assert_eq!(unrelated.code, None);
7067 assert!(!unrelated.retryable());
7068
7069 let mut client = client();
7070 let generic = QueryReadFailure::from("private query prose".to_owned());
7071 client.record_query_read(
7072 QueryOwner {
7073 id: "first",
7074 tables: &["tasks", "tasks"],
7075 },
7076 Some(&generic),
7077 );
7078 client.record_query_read(
7079 QueryOwner {
7080 id: "second",
7081 tables: &["tasks"],
7082 },
7083 Some(&generic),
7084 );
7085 client.record_query_read(
7086 QueryOwner {
7087 id: "first",
7088 tables: &["docs"],
7089 },
7090 Some(&io),
7091 );
7092 let failures = &client
7093 .diagnostics_snapshot(&Default::default())
7094 .expect("diagnostics")
7095 .query_failures;
7096 assert_eq!(
7097 failures
7098 .iter()
7099 .map(|entry| entry.id.as_str())
7100 .collect::<Vec<_>>(),
7101 vec!["second", "first"]
7102 );
7103 assert_eq!(failures[1].tables, vec!["docs"]);
7104 assert_eq!(failures[1].code, "client.storage_io");
7105
7106 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
7107 client.record_query_read(
7108 QueryOwner {
7109 id: &format!("query-{index}"),
7110 tables: &["tasks"],
7111 },
7112 Some(&generic),
7113 );
7114 }
7115 let failures = &client
7116 .diagnostics_snapshot(&Default::default())
7117 .expect("bounded diagnostics")
7118 .query_failures;
7119 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
7120 assert_eq!(failures.last().expect("last failure").id, "query-256");
7121
7122 client
7123 .query_snapshot(
7124 "SELECT id FROM tasks",
7125 &[],
7126 &[],
7127 Some(QueryOwner {
7128 id: "query-256",
7129 tables: &["tasks"],
7130 }),
7131 )
7132 .expect("successful owned read");
7133 assert!(client
7134 .diagnostics_snapshot(&Default::default())
7135 .expect("cleared diagnostics")
7136 .query_failures
7137 .iter()
7138 .all(|entry| entry.id != "query-256"));
7139 }
7140
7141 #[test]
7142 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
7143 let mut client = client();
7144 let image = Connection::open_in_memory().unwrap();
7145 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
7146 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7147 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
7148 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7149 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
7150 let table = client.schema.table("tasks").unwrap().clone();
7151 let scopes = vec![("project_id".into(), vec!["p1".into()])];
7152 assert!(client
7153 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
7154 .is_err());
7155 assert!(client.conn.is_autocommit());
7156 assert_eq!(
7157 client
7158 .query("SELECT count(*) AS n FROM tasks", &[])
7159 .unwrap()[0]["n"],
7160 1024
7161 );
7162 assert_eq!(client.drain_change_batches().len(), 1);
7163 image
7164 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
7165 .unwrap();
7166 assert!(matches!(
7167 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
7168 Ok(2050)
7169 ));
7170 assert_eq!(
7171 client
7172 .query("SELECT count(*) AS n FROM tasks", &[])
7173 .unwrap()[0]["n"],
7174 2050
7175 );
7176 assert_eq!(client.drain_change_batches().len(), 3);
7177 }
7178
7179 #[test]
7180 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
7181 let mut client = client();
7182 let base = WindowBase {
7183 table: "tasks".into(),
7184 variable: "project_id".into(),
7185 fixed_scopes: Vec::new(),
7186 params: None,
7187 };
7188 client
7189 .set_window(&base, &["p1".into(), "p2".into()])
7190 .unwrap();
7191 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7192 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7193 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
7194 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7195 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7196 client.drain_change_batches();
7197 assert!(client.set_window(&base, &["p2".into()]).is_err());
7198 assert!(client.conn.is_autocommit());
7199 assert_eq!(
7200 client
7201 .query("SELECT count(*) AS n FROM tasks", &[])
7202 .unwrap()[0]["n"],
7203 1027
7204 );
7205 assert_eq!(client.window_state(&base).units, vec!["p2"]);
7206 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
7207 assert_eq!(client.drain_change_batches().len(), 1);
7208 client
7209 .conn
7210 .execute_batch("DROP TRIGGER interrupt_evict")
7211 .unwrap();
7212 client.drain_pending_evictions().unwrap();
7213 assert!(client.load_pending_evictions().unwrap().is_empty());
7214 assert_eq!(
7215 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
7216 "held"
7217 );
7218 assert_eq!(client.drain_change_batches().len(), 2);
7219 }
7220
7221 #[test]
7222 fn eviction_marker_failures_roll_back_window_changes() {
7223 let mut client = client();
7224 let base = WindowBase {
7225 table: "tasks".into(),
7226 variable: "project_id".into(),
7227 fixed_scopes: Vec::new(),
7228 params: None,
7229 };
7230 client.set_window(&base, &["p1".into()]).unwrap();
7231 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7232 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7233 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7234 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7235 let revision = client.local_revision();
7236 assert!(client.set_window(&base, &[]).is_err());
7237 assert!(client.conn.is_autocommit());
7238 assert_eq!(client.local_revision(), revision);
7239 assert_eq!(
7240 client
7241 .query("SELECT count(*) AS n FROM tasks", &[])
7242 .unwrap()[0]["n"],
7243 2050
7244 );
7245 assert_eq!(client.window_state(&base).units, vec!["p1"]);
7246 client.conn.execute_batch("DROP TRIGGER fail_marker;
7247 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7248 assert!(client.set_window(&base, &[]).is_err());
7249 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
7250 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7251 assert!(client.drain_pending_evictions().is_err());
7252 assert!(client.conn.is_autocommit());
7253 assert_eq!(
7254 client
7255 .query("SELECT count(*) AS n FROM tasks", &[])
7256 .unwrap()[0]["n"],
7257 2
7258 );
7259 assert!(client.set_window(&base, &["p1".into()]).is_err());
7260 assert!(client.conn.is_autocommit());
7261 assert!(client.window_state(&base).units.is_empty());
7262 assert!(client.subs.is_empty());
7263 assert_eq!(
7264 client
7265 .query(
7266 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
7267 &[]
7268 )
7269 .unwrap()[0]["n"],
7270 1
7271 );
7272 client
7273 .conn
7274 .execute_batch("DROP TRIGGER fail_marker")
7275 .unwrap();
7276 client.set_window(&base, &["p1".into()]).unwrap();
7277 assert_eq!(client.window_state(&base).units, vec!["p1"]);
7278 }
7279
7280 #[test]
7281 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
7282 let mut client = client();
7283 client
7284 .conn
7285 .execute(
7286 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
7287 [],
7288 )
7289 .unwrap();
7290 assert_eq!(
7291 client.drain_pending_evictions().unwrap_err(),
7292 "sync.local_corrupt: invalid pending eviction scopes"
7293 );
7294 assert!(client.conn.is_autocommit());
7295 assert!(client.enqueue_startup_sync_if_needed().is_err());
7296 }
7297
7298 #[test]
7299 fn image_chunks_reject_null_primary_keys() {
7300 let mut client = client();
7301 let image = Connection::open_in_memory().unwrap();
7302 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7303 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
7304 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
7305 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7306 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
7307 let table = client.schema.table("tasks").unwrap().clone();
7308 assert!(client
7309 .apply_sqlite_image(
7310 &image,
7311 &table,
7312 true,
7313 &[("project_id".into(), vec!["p1".into()])],
7314 1025,
7315 7,
7316 "digest"
7317 )
7318 .is_err());
7319 assert!(client.conn.is_autocommit());
7320 assert_eq!(
7321 client
7322 .query("SELECT count(*) AS n FROM tasks", &[])
7323 .unwrap()[0]["n"],
7324 0
7325 );
7326 }
7327
7328 #[test]
7329 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
7330 for images in [false, true] {
7331 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7332 "name":name,"primaryKey":"id","columns":[
7333 {"name":"id","type":"string","nullable":false},
7334 {"name":"project_id","type":"string","nullable":false},
7335 {"name":"title","type":"string","nullable":false}],
7336 "scopes":[{"pattern":"project:{project_id}"}],
7337 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
7338 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7339 })))});
7340 let mut actual =
7341 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
7342 let mut reference =
7343 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
7344 let scopes = vec![("project_id".into(), vec!["p1".into()])];
7345 for client in [&mut actual, &mut reference] {
7346 client
7347 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
7348 .unwrap();
7349 client.subs[0].effective = Some(scopes.clone());
7350 for (table, id, title) in [
7351 ("tasks", "a", "original"),
7352 ("tasks", "b", "occupied"),
7353 ("tasks", "c", "deleted"),
7354 ("notes", "n", "untouched"),
7355 ] {
7356 client
7357 .write_base_row(
7358 table,
7359 &vec![
7360 Some(ColumnValue::String(id.into())),
7361 Some(ColumnValue::String("p1".into())),
7362 Some(ColumnValue::String(title.into())),
7363 ],
7364 1,
7365 )
7366 .unwrap();
7367 }
7368 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7369 client
7370 .mutate(vec![
7371 Mutation::Upsert {
7372 table: "tasks".into(),
7373 values: Map::from_iter([
7374 ("id".into(), Value::from("a")),
7375 ("project_id".into(), Value::from("p1")),
7376 ("title".into(), Value::from("available")),
7377 ]),
7378 base_version: None,
7379 },
7380 Mutation::Delete {
7381 table: "tasks".into(),
7382 row_id: "c".into(),
7383 base_version: None,
7384 },
7385 Mutation::Upsert {
7386 table: "tasks".into(),
7387 values: Map::from_iter([
7388 ("id".into(), Value::from("local")),
7389 ("project_id".into(), Value::from("p1")),
7390 ("title".into(), Value::from("local value")),
7391 ]),
7392 base_version: None,
7393 },
7394 Mutation::Upsert {
7395 table: "notes".into(),
7396 values: Map::from_iter([
7397 ("id".into(), Value::from("n")),
7398 ("project_id".into(), Value::from("p1")),
7399 ("title".into(), Value::from("pending note")),
7400 ]),
7401 base_version: None,
7402 },
7403 ])
7404 .unwrap();
7405 }
7406 let pending = actual.pending_commit_ids();
7407 actual.overlay_rebuild_count.set(0);
7408 for (id, title) in [
7409 ("b", "available"),
7410 ("b", "occupied"),
7411 ("a", "server original"),
7412 ("d", "local value"),
7413 ("d", "released"),
7414 ("c", "redelivered"),
7415 ] {
7416 let table = actual.schema.table("tasks").unwrap().clone();
7417 let values = vec![
7418 Some(ColumnValue::String(id.into())),
7419 Some(ColumnValue::String("p1".into())),
7420 Some(ColumnValue::String(title.into())),
7421 ];
7422 if images {
7423 let image = Connection::open_in_memory().unwrap();
7424 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7425 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7426 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
7427 image
7428 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
7429 .unwrap();
7430 assert!(matches!(
7431 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
7432 Ok(1)
7433 ));
7434 } else {
7435 let segment = RowsSegment {
7436 table: "tasks".into(),
7437 schema_version: 1,
7438 columns: table.wire_columns.clone(),
7439 blocks: vec![vec![ssp2::segment::SegmentRow {
7440 server_version: 2,
7441 values: values.clone(),
7442 }]],
7443 };
7444 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
7445 }
7446 reference.write_base_row("tasks", &values, 2).unwrap();
7447 reference.rebuild_overlay().expect("rebuild overlay");
7448 for table in ["tasks", "notes"] {
7449 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")] {
7450 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
7451 }
7452 }
7453 assert_eq!(actual.pending_commit_ids(), pending);
7454 }
7455 assert_eq!(
7456 actual.overlay_rebuild_count.get(),
7457 0,
7458 "each chunk must leave unrelated tables alone"
7459 );
7460 let before = actual
7461 .query("SELECT * FROM tasks ORDER BY id", &[])
7462 .unwrap();
7463 let fts_before = actual
7464 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
7465 .unwrap();
7466 let revision = actual.local_revision();
7467 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
7468 let table = actual.schema.table("tasks").unwrap().clone();
7469 let segment = RowsSegment {
7470 table: "tasks".into(),
7471 schema_version: 1,
7472 columns: table.wire_columns.clone(),
7473 blocks: vec![vec![ssp2::segment::SegmentRow {
7474 server_version: 3,
7475 values: vec![
7476 Some(ColumnValue::String("b".into())),
7477 Some(ColumnValue::String("p1".into())),
7478 Some(ColumnValue::String("fresh".into())),
7479 ],
7480 }]],
7481 };
7482 assert!(actual.apply_segment(0, &segment, false).is_err());
7483 assert!(actual.conn.is_autocommit());
7484 assert_eq!(
7485 actual
7486 .query("SELECT * FROM tasks ORDER BY id", &[])
7487 .unwrap(),
7488 before
7489 );
7490 assert_eq!(
7491 actual
7492 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
7493 .unwrap(),
7494 fts_before
7495 );
7496 assert_eq!(actual.local_revision(), revision);
7497 assert_eq!(actual.pending_commit_ids(), pending);
7498 }
7499 }
7500
7501 #[test]
7502 fn import_reconciliation_preserves_typed_pending_primary_keys() {
7503 for (kind, a, b) in [
7504 ("string", json!("a"), json!("b")),
7505 ("integer", json!(1), json!(2)),
7506 ("boolean", json!(true), json!(false)),
7507 ("json", json!("1"), json!("2")),
7508 ] {
7509 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}]}]});
7510 for images in [false, true] {
7511 let mut client =
7512 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
7513 let table = client.schema.table("tasks").unwrap().clone();
7514 client
7515 .subscribe("tasks".into(), "tasks".into(), vec![], None)
7516 .unwrap();
7517 for (id, title) in [(&a, "original"), (&b, "occupied")] {
7518 client
7519 .write_base_row(
7520 "tasks",
7521 &vec![
7522 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
7523 Some(ColumnValue::String(title.into())),
7524 ],
7525 1,
7526 )
7527 .unwrap();
7528 }
7529 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7530 client
7531 .mutate(vec![Mutation::Upsert {
7532 table: "tasks".into(),
7533 values: Map::from_iter([
7534 ("id".into(), a.clone()),
7535 ("title".into(), json!("available")),
7536 ]),
7537 base_version: None,
7538 }])
7539 .unwrap();
7540 client.overlay_rebuild_count.set(0);
7541 for (title, expected) in [("available", "original"), ("occupied", "available")] {
7542 let values = vec![
7543 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
7544 Some(ColumnValue::String(title.into())),
7545 ];
7546 if images {
7547 let image = Connection::open_in_memory().unwrap();
7548 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7549 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7550 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
7551 image
7552 .execute(
7553 "INSERT INTO tasks VALUES (?1,?2,2)",
7554 rusqlite::params![RowParam::Cell(&values[0]), title],
7555 )
7556 .unwrap();
7557 assert!(
7558 matches!(
7559 client.apply_sqlite_image(
7560 &image,
7561 &table,
7562 false,
7563 &[],
7564 1,
7565 7,
7566 "digest"
7567 ),
7568 Ok(1)
7569 ),
7570 "{kind}, images={images}"
7571 );
7572 } else {
7573 let segment = RowsSegment {
7574 table: "tasks".into(),
7575 schema_version: 1,
7576 columns: table.wire_columns.clone(),
7577 blocks: vec![vec![ssp2::segment::SegmentRow {
7578 server_version: 2,
7579 values,
7580 }]],
7581 };
7582 assert!(
7583 matches!(client.apply_segment(0, &segment, false), Ok(1)),
7584 "{kind}"
7585 );
7586 }
7587 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
7588 let actual: String = client
7589 .conn
7590 .query_row(
7591 "SELECT title FROM tasks WHERE id=?1",
7592 [RowParam::Cell(&key)],
7593 |row| row.get(0),
7594 )
7595 .unwrap();
7596 assert_eq!(actual, expected, "{kind}, images={images}");
7597 }
7598 assert_eq!(client.overlay_rebuild_count.get(), 0);
7599 }
7600 }
7601 }
7602
7603 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
7604 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7605 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
7606 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
7607 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7608 })))});
7609 let mut client =
7610 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
7611 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();
7612 client.rebuild_overlay().expect("rebuild overlay");
7613 client
7614 .mutate(vec![Mutation::Upsert {
7615 table: "tasks".into(),
7616 values: Map::from_iter([
7617 ("id".into(), json!("offline")),
7618 ("project_id".into(), json!("p1")),
7619 ("title".into(), json!("pending edit")),
7620 ]),
7621 base_version: None,
7622 }])
7623 .unwrap();
7624 let image = Connection::open_in_memory().unwrap();
7625 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7626 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
7627 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();
7628 image
7629 .execute(
7630 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
7631 [rows],
7632 )
7633 .unwrap();
7634 client.overlay_rebuild_count.set(0);
7635 let table = client.schema.table("tasks").unwrap().clone();
7636 (client, image, table)
7637 }
7638
7639 #[test]
7640 fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
7641 let (mut client, image, table) = unique_import_fixture(1);
7642 image
7643 .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
7644 .unwrap();
7645 client
7646 .conn
7647 .execute_batch("PRAGMA max_page_count = 1")
7648 .unwrap();
7649 let effective = [("project_id".into(), vec!["p1".into()])];
7650 assert!(client
7651 .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
7652 .is_err());
7653 let failure = client.storage_failure.borrow().clone().unwrap();
7654 assert_eq!(failure.code, Some("client.storage_full"));
7655 assert_eq!(failure.sqlite_code, Some(13));
7656 assert!(!failure.message.contains("rollback"));
7657 assert!(client.conn.is_autocommit());
7658 client
7659 .conn
7660 .execute_batch("PRAGMA max_page_count = 1073741823")
7661 .unwrap();
7662 assert!(matches!(
7663 client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
7664 Ok(1)
7665 ));
7666 assert!(client.conn.is_autocommit());
7667 }
7668
7669 #[test]
7670 fn authoring_storage_failures_are_structured_and_scoped() {
7671 let path = std::env::temp_dir().join(format!(
7672 "syncular-authoring-failure-{}.db",
7673 uuid::Uuid::new_v4()
7674 ));
7675 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7676 {"name":"id","type":"string","nullable":false},
7677 {"name":"project_id","type":"string","nullable":false},
7678 {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
7679 let mut client = SyncClient::open_path(
7680 "authoring".into(),
7681 &schema,
7682 ClientLimits::default(),
7683 path.to_str().unwrap(),
7684 )
7685 .expect("open client");
7686 client.create_synced_tables().expect("create synced tables");
7687 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
7689
7690 let upsert = |id: &str| {
7691 vec![Mutation::Upsert {
7692 table: "tasks".into(),
7693 values: Map::from_iter([
7694 ("id".into(), json!(id)),
7695 ("project_id".into(), json!("p1")),
7696 ("title".into(), json!("first")),
7697 ]),
7698 base_version: None,
7699 }]
7700 };
7701
7702 let locker = Connection::open(&path).expect("second connection");
7703 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
7704 let busy = client.mutate(upsert("t1")).expect_err("busy write");
7705 assert_eq!(busy.code, "client.storage_busy");
7706 assert!(busy.retryable);
7707 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
7708 assert!(client.pending_commit_ids().is_empty());
7709 assert_eq!(client.local_revision(), 0);
7710
7711 let invalid = client.mutate(Vec::new()).expect_err("empty commit");
7713 assert_eq!(invalid.code, "sync.invalid_request");
7714 assert_eq!(invalid.message, "the authoring request is invalid");
7715 assert!(
7716 invalid.details.as_ref().unwrap()["legacyCause"]
7717 .as_str()
7718 .unwrap()
7719 .contains("at least one operation"),
7720 "{invalid:?}"
7721 );
7722
7723 locker.execute_batch("ROLLBACK").unwrap();
7724 let id = client.mutate(upsert("t1")).expect("retry after busy");
7725 assert_eq!(client.pending_commit_ids(), vec![id]);
7726 assert_eq!(client.local_revision(), 1);
7727
7728 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
7730 let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
7731 assert_eq!(busy_again.code, "client.storage_busy");
7732 assert!(busy_again.retryable);
7733 locker.execute_batch("ROLLBACK").unwrap();
7734
7735 let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7737 rusqlite::ffi::Error::new(13),
7738 Some("database or disk is full".to_owned()),
7739 )));
7740 assert_eq!(full.code, "client.storage_full");
7741 assert!(!full.retryable);
7742 assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
7743
7744 drop(locker);
7745 drop(client);
7746 let _ = std::fs::remove_file(&path);
7747 }
7748
7749 #[test]
7750 fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
7751 let path = std::env::temp_dir().join(format!(
7752 "syncular-caller-values-{}.db",
7753 uuid::Uuid::new_v4()
7754 ));
7755 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7756 {"name":"id","type":"string","nullable":false},
7757 {"name":"project_id","type":"string","nullable":false},
7758 {"name":"title","type":"string","nullable":false},
7759 {"name":"priority","type":"integer","nullable":false},
7760 {"name":"payload","type":"bytes","nullable":false},
7761 {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
7762 let mut client = SyncClient::open_path(
7763 "caller-values".into(),
7764 &schema,
7765 ClientLimits::default(),
7766 path.to_str().unwrap(),
7767 )
7768 .expect("open client");
7769 client.create_synced_tables().expect("create synced tables");
7770
7771 let values = |id: &str| {
7772 Map::from_iter([
7773 ("id".to_owned(), json!(id)),
7774 ("project_id".to_owned(), json!("p1")),
7775 ("title".to_owned(), json!("first")),
7776 ("priority".to_owned(), json!(1)),
7777 ("payload".to_owned(), json!({"$bytes": "0a"})),
7778 ])
7779 };
7780 let upsert = |values: Map<String, Value>| {
7781 vec![Mutation::Upsert {
7782 table: "tasks".into(),
7783 values,
7784 base_version: None,
7785 }]
7786 };
7787 let seed = client.mutate(upsert(values("t1"))).expect("seed row");
7788 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
7789
7790 for (label, override_value, cause) in [
7795 ("unknown column", ("nope", json!(1)), "unknown column"),
7796 (
7797 "internal sync column",
7798 ("_sync_version", json!(1)),
7799 "internal sync column",
7800 ),
7801 ("wrong value type", ("title", json!(7)), "expected a string"),
7802 (
7803 "malformed bytes envelope",
7804 ("payload", json!({"$bytes": "zz"})),
7805 "expected exactly",
7806 ),
7807 (
7808 "odd-length bytes envelope",
7809 ("payload", json!({"$bytes": "abc"})),
7810 "expected exactly",
7811 ),
7812 (
7813 "plus-sign bytes envelope",
7814 ("payload", json!({"$bytes": "+a"})),
7815 "expected exactly",
7816 ),
7817 (
7818 "bytes envelope with a second key",
7819 ("payload", json!({"$bytes": "0a", "extra": 1})),
7820 "expected exactly",
7821 ),
7822 (
7823 "no bytes envelope",
7824 ("payload", json!({"nested": true})),
7825 "expected exactly",
7826 ),
7827 ] {
7828 let mut full = values("t2");
7829 full.insert(override_value.0.to_owned(), override_value.1);
7830 let error = client.mutate(upsert(full)).expect_err(label);
7831 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
7832 assert_eq!(error.message, "the authoring request is invalid", "{label}");
7833 assert!(!error.retryable, "{label}");
7834 let legacy = error.details.as_ref().unwrap()["legacyCause"]
7835 .as_str()
7836 .unwrap();
7837 assert!(
7838 legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
7839 "{label}: {legacy}"
7840 );
7841 assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
7842 assert_eq!(client.local_revision(), 1, "{label}");
7843 assert_eq!(
7844 client.query("SELECT * FROM tasks", &[]).unwrap(),
7845 rows,
7846 "{label}"
7847 );
7848 }
7849
7850 let mut missing = values("t3");
7853 missing.remove("payload");
7854 let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
7855 assert_eq!(missing_error.code, "sync.invalid_request");
7856 assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
7857 .as_str()
7858 .unwrap()
7859 .contains("is not nullable"));
7860
7861 let mut bad_row_id = values("t4");
7864 bad_row_id.insert("id".to_owned(), json!({"nested": true}));
7865 let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
7866 assert_eq!(row_id_error.code, "sync.invalid_request");
7867
7868 for (label, partial, cause) in [
7870 (
7871 "patch wrong value type",
7872 ("priority", json!("high")),
7873 "expected an integer",
7874 ),
7875 ("patch unknown column", ("nope", json!(1)), "unknown column"),
7876 ] {
7877 let error = client
7878 .patch(
7879 "tasks",
7880 "t1",
7881 Map::from_iter([(partial.0.to_owned(), partial.1)]),
7882 None,
7883 )
7884 .expect_err(label);
7885 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
7886 assert_eq!(error.message, "the authoring request is invalid", "{label}");
7887 assert!(
7888 error.details.as_ref().unwrap()["legacyCause"]
7889 .as_str()
7890 .unwrap()
7891 .contains(cause),
7892 "{label}"
7893 );
7894 }
7895 assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
7896 assert_eq!(client.local_revision(), 1);
7897 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
7898
7899 for (label, payload) in [
7902 ("explicit null", json!(null)),
7903 ("empty envelope", json!({"$bytes": ""})),
7904 ("uppercase envelope", json!({"$bytes": "0AFF"})),
7905 ] {
7906 let mut accepted = values("t-ok");
7907 accepted.insert("extra".to_owned(), payload);
7908 let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
7909 panic!("{label} must be accepted: {error:?}");
7910 });
7911 assert!(!encoded.is_empty(), "{label}");
7912 }
7913
7914 let next = client
7916 .mutate(upsert(values("t5")))
7917 .expect("valid after failures");
7918 assert_ne!(next, seed);
7919
7920 drop(client);
7921 let _ = std::fs::remove_file(&path);
7922 }
7923
7924 #[test]
7925 fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
7926 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7927 {"name":"id","type":"string","nullable":false},
7928 {"name":"project_id","type":"string","nullable":false},
7929 {"name":"payload","type":"bytes","nullable":true}],
7930 "scopes":[{"pattern":"project:{project_id}"}]}]});
7931 let open = |path: &std::path::Path| {
7932 SyncClient::open_path(
7933 "legacy-values".into(),
7934 &schema,
7935 ClientLimits::default(),
7936 path.to_str().unwrap(),
7937 )
7938 .expect("open client")
7939 };
7940 let row = |id: &str| {
7941 Map::from_iter([
7942 ("id".to_owned(), json!(id)),
7943 ("project_id".to_owned(), json!("p1")),
7944 ("payload".to_owned(), json!({"$bytes": "0a"})),
7945 ])
7946 };
7947 let upsert = |values| {
7948 vec![Mutation::Upsert {
7949 table: "tasks".into(),
7950 values,
7951 base_version: None,
7952 }]
7953 };
7954 for (label, stored_cell, representable) in [
7955 ("plus-sign envelope", json!({"$bytes": "+a"}), false),
7956 ("non-hex envelope", json!({"$bytes": "zz"}), false),
7957 ("odd-length envelope", json!({"$bytes": "abc"}), false),
7958 ("no envelope", json!({"nested": true}), false),
7959 ("wrong declared type", json!("high"), true),
7963 ("stored null for a non-nullable column", json!(null), true),
7964 ] {
7965 let path = std::env::temp_dir().join(format!(
7966 "syncular-legacy-values-{}-{}.db",
7967 label.replace(' ', "-"),
7968 uuid::Uuid::new_v4()
7969 ));
7970 let mut client = open(&path);
7971 client.create_synced_tables().expect("create synced tables");
7972 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
7973 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
7974 drop(client);
7975
7976 let connection = Connection::open(&path).expect("raw connection");
7978 let (column, cell) = if stored_cell.is_null() {
7979 ("project_id", stored_cell.clone())
7980 } else {
7981 ("payload", stored_cell.clone())
7982 };
7983 let mut planted_values = json!({
7984 "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
7985 });
7986 planted_values[column] = cell;
7987 let planted = json!([{
7988 "op": "upsert",
7989 "table": "tasks",
7990 "rowId": "t1",
7991 "baseVersion": null,
7992 "values": planted_values
7993 }]);
7994 connection
7995 .execute(
7996 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
7997 rusqlite::params![planted.to_string(), stale],
7998 )
7999 .expect("plant legacy intent");
8000 drop(connection);
8001
8002 let reopened = open(&path);
8005 assert_eq!(
8006 reopened.pending_commit_ids(),
8007 vec![successor.clone()],
8008 "{label}"
8009 );
8010 let rejection = reopened
8011 .rejections()
8012 .iter()
8013 .find(|rejection| rejection.client_commit_id == stale)
8014 .unwrap_or_else(|| panic!("{label}: rejection"));
8015 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8016 assert_eq!(
8017 rejection.message, "the persisted commit carries values the current codec refuses",
8018 "{label}"
8019 );
8020 assert_eq!(rejection.operation.is_some(), representable, "{label}");
8021 assert_eq!(
8022 rejection
8023 .details
8024 .as_ref()
8025 .and_then(|details| details.reason.as_deref()),
8026 Some(INVALID_STORED_VALUES_REASON),
8027 "{label}"
8028 );
8029 let journal = reopened
8031 .commit_outcome(&stale)
8032 .expect("read outcome")
8033 .expect("journal entry");
8034 assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
8035 drop(reopened);
8036 let mut reopened = open(&path);
8037 let reread = reopened
8038 .commit_outcome(&stale)
8039 .expect("read outcome")
8040 .expect("journal entry");
8041 assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
8042
8043 assert_eq!(
8046 reopened
8047 .query("SELECT id FROM tasks ORDER BY id", &[])
8048 .unwrap(),
8049 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8050 "{label}"
8051 );
8052 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8053 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8054 assert!(!reopened
8055 .drop_unencodable_outbox(false, None)
8056 .expect("idempotent prepass"));
8057 let mut transport = CountingRealtimeTransport::default();
8058 let outcome = reopened.sync(&mut transport);
8059 assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
8060 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8061 let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
8062 assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
8063 assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
8064 drop(reopened);
8065 let _ = std::fs::remove_file(&path);
8066 }
8067 }
8068
8069 #[test]
8070 fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
8071 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8072 {"name":"id","type":"string","nullable":false},
8073 {"name":"project_id","type":"string","nullable":false},
8074 {"name":"payload","type":"bytes","nullable":true}],
8075 "scopes":[{"pattern":"project:{project_id}"}]}]});
8076 for (label, column, planted) in [
8077 ("malformed envelope", "payload", json!({"$bytes": "zz"})),
8078 (
8079 "stored null for a non-nullable column",
8080 "project_id",
8081 json!(null),
8082 ),
8083 ("wrong declared type", "payload", json!(7)),
8084 ] {
8085 let path = std::env::temp_dir().join(format!(
8086 "syncular-shared-row-{}-{}.db",
8087 label.replace(' ', "-"),
8088 uuid::Uuid::new_v4()
8089 ));
8090 let open = |path: &std::path::Path| {
8091 SyncClient::open_path(
8092 "shared-row".into(),
8093 &schema,
8094 ClientLimits::default(),
8095 path.to_str().unwrap(),
8096 )
8097 .expect("open client")
8098 };
8099 let row = |id: &str| {
8100 Map::from_iter([
8101 ("id".to_owned(), json!(id)),
8102 ("project_id".to_owned(), json!("p1")),
8103 ("payload".to_owned(), json!({"$bytes": "0a"})),
8104 ])
8105 };
8106 let upsert = |values| {
8107 vec![Mutation::Upsert {
8108 table: "tasks".into(),
8109 values,
8110 base_version: None,
8111 }]
8112 };
8113 let mut client = open(&path);
8114 client.create_synced_tables().expect("create synced tables");
8115 let older = client.mutate(upsert(row("t1"))).expect("older commit");
8116 let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
8117 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8118 drop(client);
8119
8120 let connection = Connection::open(&path).expect("raw connection");
8121 for commit_id in [&older, &newer] {
8122 let mut values = row("t1");
8123 values.insert(column.to_owned(), planted.clone());
8124 let planted_ops = json!([{
8125 "op": "upsert",
8126 "table": "tasks",
8127 "rowId": "t1",
8128 "baseVersion": null,
8129 "values": values
8130 }]);
8131 connection
8132 .execute(
8133 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8134 rusqlite::params![planted_ops.to_string(), commit_id],
8135 )
8136 .expect("plant legacy intent");
8137 }
8138 drop(connection);
8139
8140 let mut reopened = open(&path);
8142 assert_eq!(
8143 reopened.pending_commit_ids(),
8144 vec![successor.clone()],
8145 "{label}"
8146 );
8147 assert_eq!(
8148 reopened
8149 .rejections()
8150 .iter()
8151 .map(|rejection| rejection.client_commit_id.clone())
8152 .collect::<Vec<_>>(),
8153 vec![older.clone(), newer.clone()],
8154 "{label}"
8155 );
8156 assert!(
8157 reopened
8158 .rejections()
8159 .iter()
8160 .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
8161 && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
8162 == Some(INVALID_STORED_VALUES_REASON)),
8163 "{label}"
8164 );
8165 for commit_id in [&older, &newer] {
8166 assert!(
8167 matches!(
8168 reopened
8169 .commit_outcome(commit_id)
8170 .expect("outcome")
8171 .map(|o| o.status),
8172 Some(CommitOutcomeStatus::Rejected)
8173 ),
8174 "{label}"
8175 );
8176 }
8177 assert_eq!(
8178 reopened
8179 .query("SELECT id FROM tasks ORDER BY id", &[])
8180 .unwrap(),
8181 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8182 "{label}"
8183 );
8184 let revision = reopened.local_revision();
8185 let batches = reopened.drain_change_batches();
8186 assert!(
8187 batches.iter().any(|batch| batch.rejections_changed
8188 && batch.outcomes_changed
8189 && batch.tables.iter().any(|change| change.table == "tasks")
8190 && batch.revision == revision.to_string()),
8191 "{label}: {batches:?}"
8192 );
8193 drop(reopened);
8194
8195 let mut stable = open(&path);
8198 assert_eq!(stable.local_revision(), revision, "{label}");
8199 assert!(stable.drain_change_batches().is_empty(), "{label}");
8200 assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
8201 drop(stable);
8202 let _ = std::fs::remove_file(&path);
8203 }
8204 }
8205
8206 #[test]
8207 fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
8208 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8209 {"name":"id","type":"string","nullable":false},
8210 {"name":"project_id","type":"string","nullable":false},
8211 {"name":"payload","type":"bytes","nullable":true}],
8212 "scopes":[{"pattern":"project:{project_id}"}]}]});
8213 for (label, second_op) in [
8214 (
8215 "delete carrying values",
8216 json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
8217 ),
8218 (
8219 "baseVersion beyond the safe bound",
8220 json!({"op": "upsert", "table": "tasks", "rowId": "t9",
8221 "baseVersion": MAX_JS_SAFE_INTEGER + 1,
8222 "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
8223 ),
8224 ] {
8225 let path = std::env::temp_dir().join(format!(
8226 "syncular-journal-shape-{}-{}.db",
8227 label.replace(' ', "-"),
8228 uuid::Uuid::new_v4()
8229 ));
8230 let open = |path: &std::path::Path| {
8231 SyncClient::open_path(
8232 "journal-shape".into(),
8233 &schema,
8234 ClientLimits::default(),
8235 path.to_str().unwrap(),
8236 )
8237 .expect("open client")
8238 };
8239 let values = |id: &str| {
8240 Map::from_iter([
8241 ("id".to_owned(), json!(id)),
8242 ("project_id".to_owned(), json!("p1")),
8243 ("payload".to_owned(), json!({"$bytes": "0a"})),
8244 ])
8245 };
8246 let upsert = |values| {
8247 vec![Mutation::Upsert {
8248 table: "tasks".into(),
8249 values,
8250 base_version: None,
8251 }]
8252 };
8253 let mut client = open(&path);
8254 client.create_synced_tables().expect("create synced tables");
8255 let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
8256 drop(client);
8257
8258 let connection = Connection::open(&path).expect("raw connection");
8259 let planted = json!([
8263 {
8264 "op": "upsert",
8265 "table": "tasks",
8266 "rowId": "t1",
8267 "baseVersion": null,
8268 "values": {"id": "t1", "project_id": null,
8269 "payload": {"$bytes": "0a"}}
8270 },
8271 second_op
8272 ]);
8273 connection
8274 .execute(
8275 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8276 rusqlite::params![planted.to_string(), stale],
8277 )
8278 .expect("plant legacy intent");
8279 drop(connection);
8280
8281 let reopened = open(&path);
8282 let journal = reopened
8283 .commit_outcome(&stale)
8284 .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
8285 .unwrap_or_else(|| panic!("{label}: journal entry"));
8286 assert!(
8287 matches!(journal.status, CommitOutcomeStatus::Rejected),
8288 "{label}"
8289 );
8290 assert!(journal.operations.is_none(), "{label}");
8291 assert!(
8292 reopened
8293 .commit_outcomes(CommitOutcomeQuery::default())
8294 .is_ok(),
8295 "{label}: outcome list query"
8296 );
8297 assert!(reopened.pending_commit_ids().is_empty(), "{label}");
8298 drop(reopened);
8299 let _ = std::fs::remove_file(&path);
8300 }
8301 }
8302
8303 #[test]
8304 fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
8305 let schema = |version: i32, extra: bool| {
8306 let mut columns = vec![
8307 json!({"name":"id","type":"string","nullable":false}),
8308 json!({"name":"project_id","type":"string","nullable":false}),
8309 json!({"name":"payload","type":"bytes","nullable":true}),
8310 ];
8311 if extra {
8312 columns.push(json!({"name":"later","type":"string","nullable":true}));
8313 }
8314 json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
8315 "scopes":[{"pattern":"project:{project_id}"}]}]})
8316 };
8317 for (label, bump) in [
8318 ("same-version recreation", false),
8319 ("version-bump reset", true),
8320 ("version-only bump reset", true),
8321 ] {
8322 let path = std::env::temp_dir().join(format!(
8323 "syncular-legacy-reset-{}-{}.db",
8324 label.replace(' ', "-"),
8325 uuid::Uuid::new_v4()
8326 ));
8327 let v1 = schema(1, false);
8328 let open = |schema: &Value, path: &std::path::Path| {
8329 SyncClient::open_path(
8330 "legacy-reset".into(),
8331 schema,
8332 ClientLimits::default(),
8333 path.to_str().unwrap(),
8334 )
8335 .expect("open client")
8336 };
8337 let row = |id: &str| {
8338 Map::from_iter([
8339 ("id".to_owned(), json!(id)),
8340 ("project_id".to_owned(), json!("p1")),
8341 ("payload".to_owned(), json!({"$bytes": "0a"})),
8342 ])
8343 };
8344 let upsert = |values| {
8345 vec![Mutation::Upsert {
8346 table: "tasks".into(),
8347 values,
8348 base_version: None,
8349 }]
8350 };
8351 let mut client = open(&v1, &path);
8352 client.create_synced_tables().expect("create synced tables");
8353 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8354 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8355 drop(client);
8356
8357 let connection = Connection::open(&path).expect("raw connection");
8358 let planted = json!([{
8359 "op": "upsert",
8360 "table": "tasks",
8361 "rowId": "t1",
8362 "baseVersion": null,
8363 "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
8364 }]);
8365 connection
8366 .execute(
8367 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8368 rusqlite::params![planted.to_string(), stale],
8369 )
8370 .expect("plant legacy intent");
8371 drop(connection);
8372
8373 let target = if bump {
8374 schema(2, label == "version-bump reset")
8375 } else {
8376 v1.clone()
8377 };
8378 let reopened = open(&target, &path);
8379 let rejection = reopened
8380 .rejections()
8381 .iter()
8382 .find(|rejection| rejection.client_commit_id == stale)
8383 .unwrap_or_else(|| panic!("{label}: rejection"));
8384 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8385 assert_eq!(
8386 rejection
8387 .details
8388 .as_ref()
8389 .and_then(|details| details.reason.as_deref()),
8390 Some(INVALID_STORED_VALUES_REASON),
8391 "{label}"
8392 );
8393 assert!(rejection.operation.is_some(), "{label}");
8394 assert_eq!(
8395 reopened.pending_commit_ids(),
8396 vec![successor.clone()],
8397 "{label}"
8398 );
8399 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8404 if label == "version-bump reset" {
8405 assert_eq!(
8406 reopened
8407 .query("SELECT id FROM tasks ORDER BY id", &[])
8408 .unwrap(),
8409 Vec::<Map<String, Value>>::new(),
8410 "{label}"
8411 );
8412 let mut next = reopened;
8416 let revision = next.local_revision();
8417 let batches = next.drain_change_batches();
8418 assert!(
8419 batches.iter().any(|batch| batch.rejections_changed
8420 && batch.outcomes_changed
8421 && batch.tables.iter().any(|change| change.table == "tasks")
8422 && batch.revision == revision.to_string()),
8423 "{label}: {batches:?}"
8424 );
8425 next.mutate(upsert(Map::from_iter([
8426 ("id".to_owned(), json!("t3")),
8427 ("project_id".to_owned(), json!("p1")),
8428 ("payload".to_owned(), json!({"$bytes": "0b"})),
8429 ("later".to_owned(), json!("v2")),
8430 ])))
8431 .expect("v2 authoring after the reset");
8432 assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
8433 assert_eq!(next.pending_payloads().len(), 2, "{label}");
8434 drop(next);
8435 } else {
8436 assert_eq!(
8437 reopened
8438 .query("SELECT id FROM tasks ORDER BY id", &[])
8439 .unwrap(),
8440 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8441 "{label}"
8442 );
8443 drop(reopened);
8444 }
8445
8446 if !bump {
8449 let mut recreated = open(&v1, &path);
8450 let fresh = recreated
8451 .mutate(upsert(row("t3")))
8452 .expect("fresh commit while open");
8453 recreated.drain_change_batches();
8454 let planted_fresh = json!([{
8455 "op": "upsert",
8456 "table": "tasks",
8457 "rowId": "t3",
8458 "baseVersion": null,
8459 "values": {"id": "t3", "project_id": "p1",
8460 "payload": {"$bytes": "zz"}}
8461 }]);
8462 let connection = Connection::open(&path).expect("raw connection");
8463 let affected = connection
8464 .execute(
8465 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8466 rusqlite::params![planted_fresh.to_string(), fresh],
8467 )
8468 .expect("plant fresh legacy intent");
8469 assert_eq!(
8470 affected, 1,
8471 "{label}: the in-place plant must hit the queued commit"
8472 );
8473 drop(connection);
8474 let revision_before = recreated.local_revision();
8475 recreated
8476 .recreate_with_schema(&v1)
8477 .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
8478 assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
8479 let fresh_rejection = recreated
8480 .rejections()
8481 .iter()
8482 .find(|rejection| rejection.client_commit_id == fresh)
8483 .expect("the in-place recovery rejection");
8484 assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8485 assert_eq!(
8486 fresh_rejection
8487 .details
8488 .as_ref()
8489 .and_then(|details| details.reason.as_deref()),
8490 Some(INVALID_STORED_VALUES_REASON),
8491 "{label}"
8492 );
8493 assert!(
8494 matches!(
8495 recreated
8496 .commit_outcome(&fresh)
8497 .expect("outcome read")
8498 .map(|outcome| outcome.status),
8499 Some(CommitOutcomeStatus::Rejected)
8500 ),
8501 "{label}"
8502 );
8503 assert!(recreated.local_revision() > revision_before, "{label}");
8504 let revision = recreated.local_revision();
8505 let batches = recreated.drain_change_batches();
8506 assert_eq!(batches.len(), 1, "{label}: one recovery batch");
8507 assert!(
8508 batches.iter().any(|batch| batch.rejections_changed
8509 && batch.outcomes_changed
8510 && batch.tables.iter().any(|change| change.table == "tasks")
8511 && batch.revision == revision.to_string()),
8512 "{label}: {batches:?}"
8513 );
8514 assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
8515 assert_eq!(
8516 recreated
8517 .query("SELECT id FROM tasks ORDER BY id", &[])
8518 .unwrap(),
8519 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8520 "{label}: the recovered commit leaves no projection"
8521 );
8522 drop(recreated);
8523 }
8524 let _ = std::fs::remove_file(&path);
8525 }
8526 }
8527
8528 #[test]
8529 fn legacy_values_recover_over_a_synced_authoritative_row() {
8530 let path = std::env::temp_dir().join(format!(
8531 "syncular-authoritative-row-{}.db",
8532 uuid::Uuid::new_v4()
8533 ));
8534 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8535 {"name":"id","type":"string","nullable":false},
8536 {"name":"project_id","type":"string","nullable":false},
8537 {"name":"title","type":"string","nullable":false},
8538 {"name":"payload","type":"bytes","nullable":true}],
8539 "scopes":[{"pattern":"project:{project_id}"}]}]});
8540 let open = |path: &std::path::Path| {
8541 SyncClient::open_path(
8542 "authoritative-row".into(),
8543 &schema,
8544 ClientLimits::default(),
8545 path.to_str().unwrap(),
8546 )
8547 .expect("open client")
8548 };
8549 let row = |title: &str| {
8550 Map::from_iter([
8551 ("id".to_owned(), json!("t1")),
8552 ("project_id".to_owned(), json!("p1")),
8553 ("title".to_owned(), json!(title)),
8554 ("payload".to_owned(), json!({"$bytes": "0a"})),
8555 ])
8556 };
8557 let upsert = |values| {
8558 vec![Mutation::Upsert {
8559 table: "tasks".into(),
8560 values,
8561 base_version: None,
8562 }]
8563 };
8564 let mut client = open(&path);
8565 client.create_synced_tables().expect("create synced tables");
8566 client
8569 .conn
8570 .execute_batch(
8571 "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
8572 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
8573 )
8574 .expect("seed authoritative row");
8575 let older = client.mutate(upsert(row("older"))).expect("older");
8576 let newer = client.mutate(upsert(row("newer"))).expect("newer");
8577 let successor = client.mutate(upsert(row("successor"))).expect("successor");
8578 drop(client);
8579
8580 let connection = Connection::open(&path).expect("raw connection");
8581 for commit_id in [&older, &newer] {
8582 let planted = json!([{
8583 "op": "upsert",
8584 "table": "tasks",
8585 "rowId": "t1",
8586 "baseVersion": null,
8587 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
8588 "payload": {"$bytes": "zz"}}
8589 }]);
8590 connection
8591 .execute(
8592 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8593 rusqlite::params![planted.to_string(), commit_id],
8594 )
8595 .expect("plant legacy intent");
8596 }
8597 drop(connection);
8598
8599 let reopened = open(&path);
8600 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8601 assert_eq!(
8602 reopened
8603 .rejections()
8604 .iter()
8605 .map(|rejection| rejection.client_commit_id.clone())
8606 .collect::<Vec<_>>(),
8607 vec![older.clone(), newer.clone()]
8608 );
8609 assert_eq!(
8612 reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
8613 Ok(vec![Map::from_iter([
8614 ("id".to_owned(), json!("t1")),
8615 ("title".to_owned(), json!("successor")),
8616 ("_syncular_version".to_owned(), json!(1)),
8617 ])])
8618 );
8619
8620 drop(reopened);
8623 let connection = Connection::open(&path).expect("raw connection");
8624 connection
8625 .execute(
8626 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8627 rusqlite::params![
8628 json!([{
8629 "op": "upsert",
8630 "table": "tasks",
8631 "rowId": "t1",
8632 "baseVersion": null,
8633 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
8634 "payload": {"$bytes": "zz"}}
8635 }])
8636 .to_string(),
8637 successor
8638 ],
8639 )
8640 .expect("plant successor intent");
8641 drop(connection);
8642 let late = open(&path);
8643 assert!(late.pending_commit_ids().is_empty());
8644 assert_eq!(
8645 late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
8646 Ok(vec![Map::from_iter([
8647 ("id".to_owned(), json!("t1")),
8648 ("title".to_owned(), json!("authoritative")),
8649 ("_syncular_version".to_owned(), json!(1)),
8650 ])])
8651 );
8652 drop(late);
8653 let _ = std::fs::remove_file(&path);
8654 }
8655
8656 #[test]
8657 fn a_failed_value_recovery_commit_restores_every_side_effect() {
8658 let path = std::env::temp_dir().join(format!(
8659 "syncular-value-recovery-commit-{}.db",
8660 uuid::Uuid::new_v4()
8661 ));
8662 let path_str = path.to_str().expect("path");
8663 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8664 {"name":"id","type":"string","nullable":false},
8665 {"name":"project_id","type":"string","nullable":false},
8666 {"name":"payload","type":"bytes","nullable":true}],
8667 "scopes":[{"pattern":"project:{project_id}"}]}]});
8668 let conn = Connection::open(path_str).expect("replica connection");
8671 let mut client = SyncClient::with_connection(
8672 "value-recovery-commit".into(),
8673 &schema,
8674 ClientLimits::default(),
8675 conn,
8676 )
8677 .expect("replica");
8678 assert_eq!(
8679 client
8680 .conn
8681 .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
8682 .expect("journal mode"),
8683 "delete"
8684 );
8685 client.create_synced_tables().expect("create synced tables");
8686 let stale = client
8687 .mutate(vec![Mutation::Upsert {
8688 table: "tasks".into(),
8689 values: Map::from_iter([
8690 ("id".to_owned(), json!("t1")),
8691 ("project_id".to_owned(), json!("p1")),
8692 ("payload".to_owned(), json!({"$bytes": "0a"})),
8693 ]),
8694 base_version: None,
8695 }])
8696 .expect("queued write");
8697 client.drain_change_batches();
8698 let affected = client
8699 .conn
8700 .execute(
8701 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8702 rusqlite::params![
8703 json!([{
8704 "op": "upsert",
8705 "table": "tasks",
8706 "rowId": "t1",
8707 "baseVersion": null,
8708 "values": {"id": "t1", "project_id": "p1",
8709 "payload": {"$bytes": "zz"}}
8710 }])
8711 .to_string(),
8712 stale
8713 ],
8714 )
8715 .expect("plant legacy intent");
8716 assert_eq!(affected, 1, "the planted row must exist");
8717 let revision = client.local_revision();
8718 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
8719 let prior_outbox = client.pending_commit_ids();
8720 let prior_rejections = client.rejections().len();
8721 let prior_changes = client.change_queue.len();
8722 let prior_intents = client.sync_intent_queue.len();
8723 let prior_failed = client.failed_commits.len();
8724 let reader = Connection::open(path_str).expect("reader");
8727 reader
8728 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
8729 .expect("reader snapshot");
8730 client
8731 .conn
8732 .busy_timeout(std::time::Duration::ZERO)
8733 .expect("busy timeout");
8734 let error = client
8735 .recreate_with_schema(&schema)
8736 .expect_err("a blocked COMMIT must refuse the recovery");
8737 assert!(error.starts_with("client.storage_busy:"), "{error}");
8738 assert!(client.conn.is_autocommit(), "the transaction is released");
8739 assert_eq!(client.local_revision(), revision);
8740 assert_eq!(client.pending_commit_ids(), prior_outbox);
8741 assert_eq!(client.rejections().len(), prior_rejections);
8742 assert_eq!(client.change_queue.len(), prior_changes);
8743 assert_eq!(client.sync_intent_queue.len(), prior_intents);
8744 assert_eq!(client.failed_commits.len(), prior_failed);
8745 assert_eq!(
8746 client.query("SELECT * FROM tasks", &[]).expect("rows"),
8747 rows
8748 );
8749 assert!(
8750 client
8751 .commit_outcome(&stale)
8752 .expect("outcome read")
8753 .is_none(),
8754 "the rolled-back recovery kept no outcome"
8755 );
8756 reader.execute_batch("ROLLBACK").expect("release reader");
8758 client
8759 .recreate_with_schema(&schema)
8760 .expect("retry after the blocked commit");
8761 assert!(client.pending_commit_ids().is_empty());
8762 assert_eq!(client.rejections().len(), prior_rejections + 1);
8763 assert!(client.local_revision() > revision);
8764 let batches = client.drain_change_batches();
8765 assert!(
8766 batches.iter().any(|batch| batch.rejections_changed
8767 && batch.outcomes_changed
8768 && batch.tables.iter().any(|change| change.table == "tasks")),
8769 "{batches:?}"
8770 );
8771 drop(client);
8772 let _ = std::fs::remove_file(&path);
8773 }
8774
8775 #[cfg(feature = "e2ee")]
8776 #[test]
8777 fn a_failed_recovery_restores_retained_failed_commits() {
8778 let path = std::env::temp_dir().join(format!(
8779 "syncular-value-recovery-retention-{}.db",
8780 uuid::Uuid::new_v4()
8781 ));
8782 let path_str = path.to_str().expect("path");
8783 let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
8784 {"name":"id","type":"string","nullable":false},
8785 {"name":"project_id","type":"string","nullable":false},
8786 {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
8787 "scopes":[{"pattern":"project:{project_id}"}]}]});
8788 let conn = Connection::open(path_str).expect("replica connection");
8789 let mut client = SyncClient::with_connection(
8790 "value-recovery-retention".into(),
8791 &schema,
8792 ClientLimits::default(),
8793 conn,
8794 )
8795 .expect("replica");
8796 client.create_synced_tables().expect("create synced tables");
8797 client.set_retain_failed_commits(true).expect("retention");
8798 client.set_encryption(crate::values::EncryptionConfig::default());
8800 client
8801 .mutate(vec![Mutation::Upsert {
8802 table: "secrets".into(),
8803 values: Map::from_iter([
8804 ("id".to_owned(), json!("s1")),
8805 ("project_id".to_owned(), json!("p1")),
8806 ("note".to_owned(), json!("secret")),
8807 ]),
8808 base_version: None,
8809 }])
8810 .expect("queued write");
8811 client.drain_change_batches();
8812 let revision = client.local_revision();
8813 let prior_outbox = client.pending_commit_ids();
8814 let prior_rejections = client.rejections().len();
8815 let prior_failed = client.failed_commits.len();
8816 let reader = Connection::open(path_str).expect("reader");
8817 reader
8818 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
8819 .expect("reader snapshot");
8820 client
8821 .conn
8822 .busy_timeout(std::time::Duration::ZERO)
8823 .expect("busy timeout");
8824 let error = client
8825 .drop_unencodable_outbox(true, None)
8826 .expect_err("a blocked COMMIT must refuse the recovery");
8827 assert!(error.starts_with("client.storage_busy:"), "{error}");
8828 assert_eq!(client.failed_commits.len(), prior_failed);
8829 assert_eq!(client.pending_commit_ids(), prior_outbox);
8830 assert_eq!(client.rejections().len(), prior_rejections);
8831 assert_eq!(client.local_revision(), revision);
8832 reader.execute_batch("ROLLBACK").expect("release reader");
8834 client
8835 .drop_unencodable_outbox(true, None)
8836 .expect("retry after the blocked commit");
8837 assert_eq!(client.failed_commits.len(), prior_failed + 1);
8838 assert!(client.pending_commit_ids().is_empty());
8839 assert_eq!(client.rejections().len(), prior_rejections + 1);
8840 drop(client);
8841 let _ = std::fs::remove_file(&path);
8842 }
8843
8844 #[test]
8845 fn client_error_maps_legacy_codes_and_static_messages() {
8846 let code_only = ClientError::from("sync.unknown_table".to_owned());
8847 assert_eq!(code_only.code, "sync.unknown_table");
8848 assert_eq!(code_only.message, "the commit targets an unknown table");
8849 assert!(code_only.details.is_none());
8850
8851 let dynamic = ClientError::from(
8852 "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
8853 );
8854 assert_eq!(dynamic.code, "sync.invalid_request");
8855 assert_eq!(dynamic.message, "the authoring request is invalid");
8856 assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
8857 .as_str()
8858 .unwrap()
8859 .contains("patch cannot write scope column"));
8860
8861 let unclassified =
8862 ClientError::from("a commit must contain at least one operation".to_owned());
8863 assert_eq!(unclassified.code, "client.failed");
8864 assert_eq!(unclassified.message, "the local authoring operation failed");
8865 assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
8866 .as_str()
8867 .unwrap()
8868 .contains("at least one operation"));
8869
8870 let metadata = ClientError::from(QueryReadFailure {
8872 code: None,
8873 sqlite_code: Some(1),
8874 sqlite_message: Some("generic SQLite prose".to_owned()),
8875 rollback_failure: Some(Box::new(QueryReadFailure::from(
8876 rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
8877 ))),
8878 message: "unclassified read".to_owned(),
8879 });
8880 assert_eq!(metadata.code, "client.failed");
8881 assert_eq!(metadata.message, "the local authoring operation failed");
8882 let details = metadata.details.as_ref().unwrap();
8883 assert_eq!(details["sqliteCode"], 1);
8884 assert_eq!(details["sqliteMessage"], "generic SQLite prose");
8885 assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
8886 assert_eq!(details["legacyCause"], "unclassified read");
8887 }
8888
8889 #[test]
8890 fn schema_marker_read_keeps_storage_classification() {
8891 let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
8892 rusqlite::ffi::Error::new(5),
8893 Some("database is locked".to_owned()),
8894 ));
8895 assert!(busy.starts_with("client.storage_busy:"), "{busy}");
8896 let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
8897 rusqlite::ffi::Error::new(1),
8898 Some("unreadable".to_owned()),
8899 ));
8900 assert_eq!(
8901 invalid,
8902 "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
8903 );
8904
8905 let path =
8908 std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
8909 let setup = Connection::open(&path).expect("setup connection");
8910 setup
8911 .execute_batch(
8912 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
8913 )
8914 .unwrap();
8915 let locker = Connection::open(&path).expect("locker connection");
8916 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
8917 let reader = Connection::open(&path).expect("reader connection");
8918 reader.busy_timeout(std::time::Duration::ZERO).unwrap();
8919 let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
8920 assert!(locked.starts_with("client.storage_busy:"), "{locked}");
8921 locker.execute_batch("ROLLBACK").unwrap();
8922 assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
8923 let rows = setup
8924 .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
8925 row.get::<_, i64>(0)
8926 })
8927 .unwrap();
8928 assert_eq!(rows, 1, "the guard performed no writes");
8929 drop(reader);
8930 drop(locker);
8931 drop(setup);
8932 let _ = std::fs::remove_file(&path);
8933 }
8934
8935 #[cfg(feature = "crdt-yjs")]
8936 #[test]
8937 fn crdt_authoring_pre_read_preserves_storage_classification() {
8938 let path =
8939 std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
8940 let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
8941 {"name":"id","type":"string","nullable":false},
8942 {"name":"project_id","type":"string","nullable":false},
8943 {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
8944 "scopes":[{"pattern":"project:{project_id}"}]}]});
8945 let mut client = SyncClient::open_path(
8946 "crdt-busy".into(),
8947 &schema,
8948 ClientLimits::default(),
8949 path.to_str().unwrap(),
8950 )
8951 .expect("open client");
8952 client.create_synced_tables().expect("create synced tables");
8953 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8954
8955 let locker = Connection::open(&path).expect("locker connection");
8956 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
8957 let busy = client
8958 .crdt_apply_update("notes", "n1", "doc", &[])
8959 .expect_err("busy crdt pre-read");
8960 assert_eq!(busy.code, "client.storage_busy");
8961 assert!(busy.retryable);
8962 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8963 assert!(client.pending_commit_ids().is_empty());
8964 assert_eq!(client.local_revision(), 0);
8965
8966 locker.execute_batch("ROLLBACK").unwrap();
8967 drop(locker);
8968 drop(client);
8969 let _ = std::fs::remove_file(&path);
8970 }
8971
8972 #[test]
8973 fn large_unique_import_copies_no_unrelated_tables() {
8974 let (mut client, image, table) = unique_import_fixture(10240);
8975 let pending = client.pending_commit_ids();
8976 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
8977 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
8978 assert!(matches!(
8979 client.apply_sqlite_image(
8980 &image,
8981 &table,
8982 true,
8983 &[("project_id".into(), vec!["p1".into()])],
8984 10240,
8985 7,
8986 "digest"
8987 ),
8988 Ok(10240)
8989 ));
8990 assert_eq!(client.overlay_rebuild_count.get(), 0);
8991 assert_eq!(client.pending_commit_ids(), pending);
8992 assert_eq!(
8993 client
8994 .query("SELECT count(*) AS n FROM tasks", &[])
8995 .unwrap()[0]["n"],
8996 10241
8997 );
8998 assert_eq!(
8999 client
9000 .query("SELECT count(*) AS n FROM tasks_fts", &[])
9001 .unwrap()[0]["n"],
9002 10241
9003 );
9004 assert_eq!(
9005 client
9006 .query("SELECT count(*) AS n FROM notes_fts", &[])
9007 .unwrap()[0]["n"],
9008 8192
9009 );
9010 }
9011
9012 #[test]
9013 #[ignore = "manual scaling benchmark; see bench/README.md"]
9014 fn benchmark_unique_image_import() {
9015 let mut samples = Vec::new();
9016 for rows in [10240, 51200, 102400] {
9017 for trial in 0..4 {
9018 let (mut client, image, table) = unique_import_fixture(rows);
9019 let start = std::time::Instant::now();
9020 assert!(
9021 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)
9022 );
9023 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
9024 assert_eq!(
9025 client
9026 .query("SELECT count(*) AS n FROM tasks", &[])
9027 .unwrap()[0]["n"],
9028 rows + 1
9029 );
9030 assert_eq!(
9031 client
9032 .query("SELECT count(*) AS n FROM tasks_fts", &[])
9033 .unwrap()[0]["n"],
9034 rows + 1
9035 );
9036 assert_eq!(
9037 client
9038 .query("SELECT count(*) AS n FROM notes_fts", &[])
9039 .unwrap()[0]["n"],
9040 8192
9041 );
9042 assert_eq!(client.pending_commit_ids().len(), 1);
9043 if trial > 0 {
9044 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
9045 }
9046 }
9047 }
9048 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});
9049 let output = serde_json::to_string_pretty(&artifact).unwrap();
9050 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
9051 use std::io::Write;
9052 let mut file = std::fs::OpenOptions::new()
9053 .write(true)
9054 .create_new(true)
9055 .open(path)
9056 .unwrap();
9057 file.write_all(output.as_bytes()).unwrap();
9058 } else {
9059 println!("{output}");
9060 }
9061 }
9062
9063 #[test]
9064 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
9065 let schema = json!({
9066 "version": 1,
9067 "tables": [{
9068 "name": "catalogue_codes",
9069 "primaryKey": "id",
9070 "columns": [
9071 { "name": "id", "type": "string", "nullable": false },
9072 { "name": "release_id", "type": "string", "nullable": false },
9073 { "name": "code", "type": "string", "nullable": false },
9074 { "name": "title", "type": "string", "nullable": false }
9075 ],
9076 "scopes": [{ "pattern": "release:{release_id}" }],
9077 "ftsIndexes": [{
9078 "name": "catalogue_codes_fts",
9079 "columns": ["code", "title"],
9080 "tokenize": "unicode61 remove_diacritics 2"
9081 }]
9082 }]
9083 });
9084 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
9085 .expect("FTS5 client");
9086 let insert_trigger: String = client
9087 .conn
9088 .query_row(
9089 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
9090 [],
9091 |row| row.get(0),
9092 )
9093 .expect("insert trigger");
9094 let replace_guard: String = client
9095 .conn
9096 .query_row(
9097 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
9098 [],
9099 |row| row.get(0),
9100 )
9101 .expect("replace guard");
9102 assert!(!insert_trigger.contains("DELETE FROM"));
9103 assert!(replace_guard.contains("BEFORE INSERT"));
9104 assert!(replace_guard.contains("WHEN EXISTS"));
9105 let search = |client: &SyncClient, query: &str| {
9106 client
9107 .query(
9108 "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",
9109 &[Value::from(query)],
9110 )
9111 .expect("FTS query")
9112 };
9113
9114 client
9115 .mutate(vec![Mutation::Upsert {
9116 table: "catalogue_codes".to_owned(),
9117 values: Map::from_iter([
9118 ("id".to_owned(), Value::from("c1")),
9119 ("release_id".to_owned(), Value::from("r1")),
9120 ("code".to_owned(), Value::from("A01")),
9121 ("title".to_owned(), Value::from("Cholera")),
9122 ]),
9123 base_version: None,
9124 }])
9125 .expect("insert code");
9126 assert_eq!(search(&client, "cholera").len(), 1);
9127 let prior = client
9128 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9129 .unwrap();
9130 client
9131 .conn
9132 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
9133 .unwrap();
9134 client.create_synced_tables().unwrap();
9135 assert_eq!(
9136 client
9137 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9138 .unwrap(),
9139 prior
9140 );
9141 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();
9142 let rendered = serde_json::to_string(&plan).unwrap();
9143 assert!(rendered.contains("COVERING INDEX"));
9144 assert!(rendered.contains("INDEX 0:="));
9145 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
9146 assert_eq!(search(&client, "cholera").len(), 1);
9147
9148 client
9149 .mutate(vec![Mutation::Upsert {
9150 table: "catalogue_codes".to_owned(),
9151 values: Map::from_iter([
9152 ("id".to_owned(), Value::from("c1")),
9153 ("release_id".to_owned(), Value::from("r1")),
9154 ("code".to_owned(), Value::from("A01")),
9155 ("title".to_owned(), Value::from("Enteric infection")),
9156 ]),
9157 base_version: None,
9158 }])
9159 .expect("update code");
9160 assert!(search(&client, "cholera").is_empty());
9161 assert_eq!(search(&client, "enteric").len(), 1);
9162
9163 client
9164 .mutate(vec![Mutation::Delete {
9165 table: "catalogue_codes".to_owned(),
9166 row_id: "c1".to_owned(),
9167 base_version: None,
9168 }])
9169 .expect("delete code");
9170 assert!(search(&client, "enteric").is_empty());
9171 }
9172
9173 #[test]
9174 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
9175 let schema = json!({
9176 "version": 1,
9177 "tables": [{
9178 "name": "patient_notes",
9179 "primaryKey": "id",
9180 "columns": [
9181 { "name": "id", "type": "string", "nullable": false },
9182 { "name": "practice_id", "type": "string", "nullable": false },
9183 { "name": "encryption_key_id", "type": "string", "nullable": false },
9184 { "name": "title", "type": "string", "nullable": false }
9185 ],
9186 "scopes": [{ "pattern": "practice:{practice_id}" }],
9187 "ftsIndexes": [{
9188 "name": "patient_notes_fts",
9189 "columns": ["title"],
9190 "tokenize": "unicode61 remove_diacritics 2"
9191 }]
9192 }]
9193 });
9194 let mut client = SyncClient::new(
9195 "local-purge-test".to_owned(),
9196 &schema,
9197 ClientLimits::default(),
9198 )
9199 .expect("local purge client");
9200 client
9201 .conn
9202 .execute(
9203 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
9204 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
9205 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
9206 [],
9207 )
9208 .expect("seed base rows");
9209 client.overlay_dirty.set(true);
9210 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
9211
9212 let note = |id: &str, key_id: &str, title: &str| {
9213 Map::from_iter([
9214 ("id".to_owned(), Value::from(id)),
9215 ("practice_id".to_owned(), Value::from("practice-1")),
9216 ("encryption_key_id".to_owned(), Value::from(key_id)),
9217 ("title".to_owned(), Value::from(title)),
9218 ])
9219 };
9220 let doomed = client
9221 .mutate(vec![
9222 Mutation::Upsert {
9223 table: "patient_notes".to_owned(),
9224 values: note("target", "key-revoked", "Target changed"),
9225 base_version: None,
9226 },
9227 Mutation::Upsert {
9228 table: "patient_notes".to_owned(),
9229 values: note("unrelated", "key-held", "Unrelated changed"),
9230 base_version: None,
9231 },
9232 ])
9233 .expect("doomed commit");
9234 let kept = client
9235 .mutate(vec![Mutation::Upsert {
9236 table: "patient_notes".to_owned(),
9237 values: note("kept", "key-held", "Kept optimistic"),
9238 base_version: None,
9239 }])
9240 .expect("kept commit");
9241 client.drain_change_batches();
9242
9243 let input = LocalDataPurgeInput {
9244 purge_id: "purge-001".to_owned(),
9245 targets: vec![LocalDataPurgeTarget {
9246 table: "patient_notes".to_owned(),
9247 selectors: BTreeMap::from([(
9248 "encryption_key_id".to_owned(),
9249 vec!["key-revoked".to_owned()],
9250 )]),
9251 }],
9252 };
9253 assert_eq!(
9254 client.purge_local_data(&input).expect("apply purge"),
9255 LocalDataPurgeResult {
9256 already_applied: false,
9257 purged_rows: 1,
9258 dropped_commits: 1,
9259 }
9260 );
9261 let rows = client
9262 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
9263 .expect("visible rows");
9264 assert_eq!(rows.len(), 2);
9265 assert_eq!(rows[0]["id"], "kept");
9266 assert_eq!(rows[1]["id"], "unrelated");
9267 assert_eq!(rows[1]["title"], "Unrelated original");
9268 let fts = client
9269 .query(
9270 "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'",
9271 &[],
9272 )
9273 .expect("fts query");
9274 assert!(fts.is_empty());
9275 assert_eq!(client.outbox.len(), 1);
9276 assert_eq!(client.outbox[0].client_commit_id, kept);
9277 let outcome = client
9278 .commit_outcome(&doomed)
9279 .expect("read doomed outcome")
9280 .expect("doomed outcome");
9281 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
9282 match &outcome.results[0] {
9283 CommitOperationOutcome::Error { rejection } => {
9284 assert_eq!(rejection.code, "client.local_data_purged");
9285 assert_eq!(rejection.client_commit_id, doomed);
9286 }
9287 other => panic!("expected local purge rejection, got {other:?}"),
9288 }
9289 assert_eq!(client.drain_change_batches().len(), 1);
9290 assert_eq!(
9291 client.purge_local_data(&input).expect("retry purge"),
9292 LocalDataPurgeResult {
9293 already_applied: true,
9294 purged_rows: 0,
9295 dropped_commits: 0,
9296 }
9297 );
9298 let conflicting = LocalDataPurgeInput {
9299 purge_id: input.purge_id.clone(),
9300 targets: vec![LocalDataPurgeTarget {
9301 table: "patient_notes".to_owned(),
9302 selectors: BTreeMap::from([(
9303 "encryption_key_id".to_owned(),
9304 vec!["key-held".to_owned()],
9305 )]),
9306 }],
9307 };
9308 assert!(client
9309 .purge_local_data(&conflicting)
9310 .expect_err("id collision must fail")
9311 .contains("already used with a different plan"));
9312 }
9313}
9314
9315impl SubState {
9316 fn name(self) -> &'static str {
9317 match self {
9318 SubState::Active => "active",
9319 SubState::Revoked => "revoked",
9320 SubState::Failed => "failed",
9321 }
9322 }
9323
9324 fn parse(value: &str) -> Self {
9325 match value {
9326 "revoked" => Self::Revoked,
9327 "failed" => Self::Failed,
9328 _ => Self::Active,
9329 }
9330 }
9331}
9332
9333#[derive(Debug, Clone)]
9334struct Subscription {
9335 id: String,
9336 table: String,
9337 requested: Vec<(String, Vec<String>)>,
9338 params: Option<String>,
9339 cursor: i64,
9340 bootstrap_state: Option<String>,
9342 state: SubState,
9343 reason_code: Option<String>,
9344 effective: Option<Vec<(String, Vec<String>)>>,
9347 synced_once: bool,
9348}
9349
9350#[cfg(test)]
9351mod subscription_rearm_tests {
9352 use super::*;
9353 use serde_json::json;
9354
9355 fn client() -> SyncClient {
9356 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9357 {"name":"id","type":"string","nullable":false},
9358 {"name":"project_id","type":"string","nullable":false}],
9359 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[],"ftsIndexes":[]}]});
9360 SyncClient::new("rearm".into(), &schema, ClientLimits::default()).expect("client")
9361 }
9362
9363 fn scopes() -> Vec<(String, Vec<String>)> {
9364 vec![("project_id".to_owned(), vec!["p1".to_owned()])]
9365 }
9366
9367 fn store_revoked(client: &mut SyncClient) {
9369 client
9370 .subscribe("sub".into(), "tasks".into(), scopes(), None)
9371 .expect("subscribe");
9372 let sub = &mut client.subs[0];
9373 sub.cursor = 41;
9374 sub.bootstrap_state = Some("resume".to_owned());
9375 sub.effective = Some(scopes());
9376 sub.state = SubState::Revoked;
9377 sub.reason_code = Some("sync.scope_revoked".to_owned());
9378 client.persist_sub(&client.subs[0]).expect("persist");
9379 client.sync_needed = false;
9380 client.sync_intent_queue.clear();
9381 }
9382
9383 #[test]
9384 fn resubscribing_the_same_intent_rearms_a_revoked_registration() {
9385 let mut client = client();
9386 store_revoked(&mut client);
9387
9388 client
9389 .subscribe("sub".into(), "tasks".into(), scopes(), None)
9390 .expect("re-arm");
9391
9392 let view = client.subscription_state("sub").expect("state");
9393 assert_eq!(view.status, "active");
9394 assert_eq!(view.cursor, -1);
9395 assert!(!view.has_resume_token);
9396 assert_eq!(view.effective_scopes, None);
9397 assert_eq!(view.reason_code, None);
9398 assert!(client.sync_needed, "a re-arm asks for a pull");
9399 assert!(matches!(
9400 client.drain_sync_intents().as_slice(),
9401 [SyncIntent::Interactive]
9402 ));
9403 let stored: String = client
9404 .conn
9405 .query_row(
9406 "SELECT state_json FROM _syncular_subscriptions WHERE id = 'sub'",
9407 [],
9408 |row| row.get(0),
9409 )
9410 .expect("persisted row");
9411 let stored: Value = serde_json::from_str(&stored).expect("stored json");
9412 assert_eq!(stored["status"], json!("active"));
9413 assert_eq!(stored["cursor"], json!(-1));
9414 assert_eq!(stored["bootstrapState"], Value::Null);
9415 assert_eq!(stored["effectiveScopes"], Value::Null);
9416 assert_eq!(stored["reasonCode"], Value::Null);
9417 }
9418
9419 #[test]
9420 fn a_failed_registration_is_not_rearmed() {
9421 let mut client = client();
9422 client
9423 .subscribe("sub".into(), "tasks".into(), scopes(), None)
9424 .expect("subscribe");
9425 client.subs[0].state = SubState::Failed;
9426 client.subs[0].reason_code = Some("sync.scope_revoked".to_owned());
9427 client.persist_sub(&client.subs[0]).expect("persist");
9428 client.sync_needed = false;
9429 client.sync_intent_queue.clear();
9430
9431 client
9432 .subscribe("sub".into(), "tasks".into(), scopes(), None)
9433 .expect("subscribe");
9434
9435 let view = client.subscription_state("sub").expect("state");
9436 assert_eq!(view.status, "failed");
9437 assert_eq!(view.reason_code.as_deref(), Some("sync.scope_revoked"));
9438 assert!(!client.sync_needed);
9439 assert!(client.drain_sync_intents().is_empty());
9440 }
9441}
9442
9443#[derive(Debug, Clone)]
9444struct OutboxOp {
9445 upsert: bool,
9446 table: String,
9447 row_id: String,
9448 base_version: Option<i64>,
9449 values: Option<Map<String, Value>>,
9453}
9454
9455impl From<&OutboxOp> for CommitOperation {
9456 fn from(operation: &OutboxOp) -> Self {
9457 Self {
9458 table: operation.table.clone(),
9459 row_id: operation.row_id.clone(),
9460 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
9461 base_version: operation.base_version,
9462 values: operation.values.clone(),
9463 }
9464 }
9465}
9466
9467#[derive(Debug, Clone)]
9468struct OutboxCommit {
9469 client_commit_id: String,
9470 ops: Vec<OutboxOp>,
9471}
9472
9473fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
9477 commit
9478 .ops
9479 .iter()
9480 .filter(|operation| operation.upsert)
9481 .map(|operation| {
9482 (
9483 operation.table.as_str(),
9484 operation
9485 .values
9486 .as_ref()
9487 .map(|values| values.keys().map(String::as_str).collect())
9488 .unwrap_or_default(),
9489 )
9490 })
9491 .collect()
9492}
9493
9494type FailedCommit = (OutboxCommit, Map<String, Value>);
9495
9496#[derive(Debug, Clone)]
9497struct CompiledLocalDataPurgeTarget {
9498 table: String,
9499 selectors: Vec<(String, Vec<String>)>,
9500}
9501
9502struct StoredCommitOutcomeRow {
9503 sequence: i64,
9504 client_commit_id: String,
9505 status: String,
9506 recorded_at_ms: i64,
9507 results_json: String,
9508 operations_json: Option<String>,
9509 resolution: String,
9510 resolved_at_ms: Option<i64>,
9511 replacement_client_commit_id: Option<String>,
9512}
9513
9514impl StoredCommitOutcomeRow {
9515 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
9516 Ok(Self {
9517 sequence: row.get(0)?,
9518 client_commit_id: row.get(1)?,
9519 status: row.get(2)?,
9520 recorded_at_ms: row.get(3)?,
9521 results_json: row.get(4)?,
9522 operations_json: row.get(5)?,
9523 resolution: row.get(6)?,
9524 resolved_at_ms: row.get(7)?,
9525 replacement_client_commit_id: row.get(8)?,
9526 })
9527 }
9528}
9529
9530enum SectionError {
9533 FailClosed,
9534 Abort(String, String),
9535}
9536
9537#[derive(Debug)]
9538pub(crate) struct RequestMeta {
9539 pushed_ids: Vec<String>,
9540 fresh: Vec<(String, bool)>,
9543 pub(crate) accept: u8,
9544 deferred_commits: usize,
9548}
9549
9550#[derive(Default)]
9551struct ChangeAccumulator {
9552 tables: BTreeMap<String, Option<BTreeSet<String>>>,
9554 windows: BTreeMap<(String, String), BTreeSet<String>>,
9555 status: bool,
9556 conflicts: bool,
9557 rejections: bool,
9558 outcomes: bool,
9559}
9560
9561impl ChangeAccumulator {
9562 fn table(&mut self, table: &str) {
9563 self.tables.insert(table.to_owned(), None);
9564 }
9565
9566 fn scope(&mut self, table: &str, key: String) {
9567 match self.tables.get_mut(table) {
9568 Some(None) => {}
9569 Some(Some(keys)) => {
9570 keys.insert(key);
9571 }
9572 None => {
9573 self.tables
9574 .insert(table.to_owned(), Some(BTreeSet::from([key])));
9575 }
9576 }
9577 }
9578
9579 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
9580 self.windows
9581 .entry((base_key.to_owned(), table.to_owned()))
9582 .or_default()
9583 .insert(unit.to_owned());
9584 }
9585
9586 fn touched(&self) -> bool {
9587 !self.tables.is_empty()
9588 || !self.windows.is_empty()
9589 || self.status
9590 || self.conflicts
9591 || self.rejections
9592 || self.outcomes
9593 }
9594}
9595
9596const PUSH_OPS_PER_REQUEST: usize = 500;
9602const MAX_LOCAL_PURGE_TARGETS: usize = 64;
9603const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
9604const MAX_LOCAL_PURGE_VALUES: usize = 128;
9605const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
9606pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
9607
9608struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
9610
9611impl OverlayDirty {
9612 fn clean() -> Self {
9613 Self(RefCell::new(Some(BTreeSet::new())))
9614 }
9615 fn get(&self) -> bool {
9616 self.0
9617 .borrow()
9618 .as_ref()
9619 .is_none_or(|tables| !tables.is_empty())
9620 }
9621 fn set(&self, dirty: bool) {
9622 *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
9623 }
9624 fn table(&self, table: &str) {
9625 if let Some(tables) = self.0.borrow_mut().as_mut() {
9626 tables.insert(table.to_owned());
9627 }
9628 }
9629 fn snapshot(&self) -> Option<BTreeSet<String>> {
9630 self.0.borrow().clone()
9631 }
9632 fn restore(&self, tables: Option<BTreeSet<String>>) {
9633 *self.0.borrow_mut() = tables;
9634 }
9635}
9636
9637pub struct SyncClient {
9638 #[cfg(feature = "bench-internals")]
9639 benchmark_phases: Recorder,
9640 conn: Connection,
9641 schema: ClientSchema,
9642 client_id: String,
9643 limits: ClientLimits,
9644 subs: Vec<Subscription>,
9645 outbox: Vec<OutboxCommit>,
9646 failed_commits: Vec<FailedCommit>,
9647 retain_failed_commits: bool,
9648 conflicts: Vec<ConflictRecord>,
9649 rejections: Vec<RejectionRecord>,
9650 schema_floor: Option<SchemaFloor>,
9651 lease_state: Option<LeaseState>,
9653 stopped: bool,
9655 upgrading: bool,
9657 sync_needed: bool,
9659 realtime_policy: RealtimePolicy,
9662 realtime_state: RealtimeState,
9663 realtime_reason_code: Option<String>,
9664 realtime_retry_delay_ms: Option<u64>,
9665 presence: HashMap<String, HashMap<String, PresencePeer>>,
9667 now_ms: Option<i64>,
9670 encryption: crate::values::EncryptionConfig,
9674 security_preflight: bool,
9677 transport_enabled: bool,
9678 previous_version: Option<PreviousVersionContextConfig>,
9682 insert_sql: RefCell<HashMap<String, String>>,
9688 overlay_dirty: OverlayDirty,
9691 storage_failure: RefCell<Option<QueryReadFailure>>,
9693 #[cfg(test)]
9696 overlay_rebuild_count: Cell<usize>,
9697 #[cfg(test)]
9698 acknowledged_replay_count: Cell<usize>,
9699 #[cfg(test)]
9702 outcome_prune_count: Cell<usize>,
9703 progress: ProgressObserver,
9705 change_queue: VecDeque<ClientChangeBatch>,
9706 sync_intent_queue: VecDeque<SyncIntent>,
9707 retry_delay_ms: u64,
9709 round_retry_delay_ms: Option<u64>,
9711 active_round: Option<uuid::Uuid>,
9712 last_round: Option<DiagnosticLastRound>,
9713 last_change: Option<DiagnosticLastChange>,
9714 query_failures: Vec<DiagnosticQueryFailure>,
9716 coalesced: Option<CoalescedApply>,
9718 coalesced_failure: Option<String>,
9721}
9722
9723struct CoalescedApply {
9727 rows: usize,
9728 subs: Vec<Subscription>,
9729 outbox: Vec<OutboxCommit>,
9730 failed_commits: Vec<FailedCommit>,
9731 conflicts: Vec<ConflictRecord>,
9732 rejections: Vec<RejectionRecord>,
9733 schema_floor: Option<SchemaFloor>,
9734 lease_state: Option<LeaseState>,
9735 stopped: bool,
9736 upgrading: bool,
9737 sync_needed: bool,
9738 overlay_dirty: Option<BTreeSet<String>>,
9739 change_queue: usize,
9740 sync_intent_queue: usize,
9741 last_change: Option<DiagnosticLastChange>,
9742}
9743
9744const COALESCED_APPLY_ROWS: usize = 1024;
9745
9746pub(crate) fn quote_ident(name: &str) -> String {
9747 format!("\"{}\"", name.replace('"', "\"\""))
9748}
9749pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
9753 conn.query_row(
9754 "SELECT value FROM _syncular_meta WHERE key = ?1",
9755 rusqlite::params![key],
9756 |row| row.get::<_, String>(0),
9757 )
9758 .ok()
9759}
9760
9761pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
9762 let _ = conn.execute(
9763 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
9764 rusqlite::params![key, value],
9765 );
9766}
9767
9768pub(crate) fn meta_delete(conn: &Connection, key: &str) {
9769 let _ = conn.execute(
9770 "DELETE FROM _syncular_meta WHERE key = ?1",
9771 rusqlite::params![key],
9772 );
9773}
9774
9775fn row_id_predicate(table: &TableSchema) -> String {
9777 let key = quote_ident(&table.primary_key);
9778 let text_match = format!("CAST({key} AS TEXT) = ?1");
9779 match table.columns[table.pk_index].ty {
9780 ColumnType::String | ColumnType::Json => {
9781 format!("{key} = ?1 AND {text_match}")
9782 }
9783 ColumnType::Integer | ColumnType::Boolean => {
9787 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
9788 }
9789 _ => text_match,
9792 }
9793}
9794
9795fn is_local_operation_code_like(value: &str) -> bool {
9796 let bytes = value.as_bytes();
9797 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
9798 && bytes
9799 .iter()
9800 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
9801}
9802
9803fn base_table(name: &str) -> String {
9804 quote_ident(&format!("_syncular_base_{name}"))
9805}
9806
9807type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
9812
9813fn window_base_key(base: &WindowBase) -> String {
9814 format!(
9815 "{}\0{}\0{}",
9816 base.table,
9817 base.variable,
9818 canonical_scope_json(&base.fixed_scopes)
9819 )
9820}
9821
9822fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
9824 let mut scopes = base.fixed_scopes.clone();
9825 scopes.retain(|(k, _)| k != &base.variable);
9826 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
9827 scopes
9828}
9829
9830fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
9834 let canonical = canonical_scope_json(&unit_scopes(base, unit));
9835 let digest = Sha256::digest(canonical.as_bytes());
9836 let hex = bytes_to_hex(&digest);
9837 format!("w:{}:{}", base.table, &hex[..16])
9838}
9839
9840fn visible_table(name: &str) -> String {
9841 quote_ident(name)
9842}
9843
9844const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
9845
9846fn is_synced_table_name(name: &str) -> bool {
9849 if name.starts_with("sqlite_") {
9850 return false;
9851 }
9852 if name.starts_with("_syncular_base_") {
9853 return true; }
9855 !name.starts_with("_syncular_")
9857}
9858
9859fn blob_id_for(bytes: &[u8]) -> String {
9861 let digest = Sha256::digest(bytes);
9862 format!("sha256:{}", bytes_to_hex(&digest))
9863}
9864
9865enum RowParam<'a> {
9869 Cell(&'a Option<ColumnValue>),
9870 Version(i64),
9871}
9872
9873impl rusqlite::ToSql for RowParam<'_> {
9874 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
9875 Ok(match self {
9876 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
9877 RowParam::Cell(cell) => match cell {
9878 None => ToSqlOutput::Owned(SqlValue::Null),
9879 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
9880 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
9881 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
9882 Some(ColumnValue::Boolean(b)) => {
9883 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
9884 }
9885 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
9886 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
9887 }
9888 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
9890 ToSqlOutput::Borrowed(ValueRef::Blob(b))
9891 }
9892 },
9893 })
9894 }
9895}
9896
9897fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
9899 match param.to_sql().map_err(|error| error.to_string())? {
9900 ToSqlOutput::Owned(value) => Ok(value),
9901 ToSqlOutput::Borrowed(value) => {
9902 SqlValue::try_from(value).map_err(|error| error.to_string())
9903 }
9904 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
9905 }
9906}
9907
9908fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
9916 use ssp2::segment::ColumnType;
9917 let mismatch = || {
9918 Err(format!(
9919 "image column {:?} holds a value of the wrong type",
9920 column.name
9921 ))
9922 };
9923 match value {
9924 ValueRef::Null => {
9925 if !column.nullable {
9926 return Err(format!(
9927 "image column {:?} is NULL but not nullable",
9928 column.name
9929 ));
9930 }
9931 Ok(ToSqlOutput::Owned(SqlValue::Null))
9932 }
9933 ValueRef::Integer(i) => match column.ty {
9934 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
9935 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
9936 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
9937 _ => mismatch(),
9938 },
9939 ValueRef::Real(_) => match column.ty {
9940 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
9941 _ => mismatch(),
9942 },
9943 ValueRef::Text(t) => {
9944 std::str::from_utf8(t)
9945 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
9946 match column.ty {
9947 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
9948 Ok(ToSqlOutput::Borrowed(value))
9949 }
9950 _ => mismatch(),
9951 }
9952 }
9953 ValueRef::Blob(_) => match column.ty {
9954 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
9956 _ => mismatch(),
9957 },
9958 }
9959}
9960
9961pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
9962 use rusqlite::types::ValueRef;
9963 match value {
9964 ValueRef::Null => Value::Null,
9965 ValueRef::Integer(i) => match column.ty {
9966 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
9967 ssp2::segment::ColumnType::Float => {
9968 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
9969 }
9970 _ => Value::from(i),
9971 },
9972 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
9973 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
9974 ValueRef::Blob(b) => {
9975 let mut map = Map::new();
9976 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
9977 Value::Object(map)
9978 }
9979 }
9980}
9981
9982fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
9986 Ok(match value {
9987 Value::Null => SqlValue::Null,
9988 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
9989 Value::Number(n) => {
9990 if let Some(i) = n.as_i64() {
9991 SqlValue::Integer(i)
9992 } else if let Some(f) = n.as_f64() {
9993 SqlValue::Real(f)
9994 } else {
9995 return Err(format!("query param number {n} is out of range"));
9996 }
9997 }
9998 Value::String(s) => SqlValue::Text(s.clone()),
9999 Value::Object(_) => {
10000 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
10001 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
10002 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
10003 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
10004 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
10005 })?)
10006 } else {
10007 return Err(
10008 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
10009 .to_owned(),
10010 );
10011 }
10012 }
10013 Value::Array(_) => return Err("query array params are not supported".to_owned()),
10014 })
10015}
10016
10017fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
10022 use rusqlite::types::ValueRef;
10023 match value {
10024 ValueRef::Null => Value::Null,
10025 ValueRef::Integer(i) => {
10026 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
10030 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
10031 Value::from(i)
10032 } else {
10033 let mut map = Map::new();
10034 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
10035 Value::Object(map)
10036 }
10037 }
10038 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
10039 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
10040 ValueRef::Blob(b) => {
10041 let mut map = Map::new();
10042 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
10043 Value::Object(map)
10044 }
10045 }
10046}
10047
10048fn query_connection(
10050 conn: &Connection,
10051 sql: &str,
10052 params: &[QueryValue],
10053) -> Result<Vec<QueryRow>, QueryReadFailure> {
10054 crate::query_guard::assert_read_only_query(sql)?;
10055 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
10056 let bound: Vec<SqlValue> = params
10057 .iter()
10058 .map(json_param_to_sql)
10059 .collect::<Result<_, _>>()?;
10060 let mut stmt = conn.prepare(&lowered_sql)?;
10061 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
10062 let bound_refs: Vec<&dyn rusqlite::ToSql> =
10063 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
10064 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
10065 let mut out = Vec::new();
10066 while let Some(row) = sql_rows.next()? {
10067 let mut record = Map::new();
10068 for (i, name) in column_names.iter().enumerate() {
10069 let value = row.get_ref(i)?;
10070 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
10071 }
10072 out.push(record);
10073 }
10074 Ok(out)
10075}
10076
10077fn persisted_window_state(
10078 conn: &Connection,
10079 base: &WindowBase,
10080) -> Result<WindowState, QueryReadFailure> {
10081 let mut stmt = conn.prepare(
10082 "SELECT windows.unit, subscriptions.state_json
10083 FROM _syncular_windows AS windows
10084 JOIN _syncular_subscriptions AS subscriptions
10085 ON subscriptions.id = windows.sub_id
10086 WHERE windows.base = ?1
10087 ORDER BY windows.unit ASC",
10088 )?;
10089 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
10090 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10091 })?;
10092 let mut units = Vec::new();
10093 let mut pending = Vec::new();
10094 for row in rows {
10095 let (unit, raw) = row?;
10096 let state: Value = serde_json::from_str(&raw)
10097 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
10098 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
10099 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
10100 || state
10101 .get("bootstrapState")
10102 .is_some_and(|value| !value.is_null());
10103 if is_pending {
10104 pending.push(unit.clone());
10105 }
10106 units.push(unit);
10107 }
10108 Ok(WindowState { units, pending })
10109}
10110
10111#[derive(Debug, Clone, Copy)]
10113pub struct SnapshotStatement<'a> {
10114 pub sql: &'a str,
10115 pub params: &'a [Value],
10116}
10117
10118#[derive(Debug, Clone, Default)]
10122pub struct SnapshotReadRequest<'a> {
10123 pub statements: Vec<SnapshotStatement<'a>>,
10124 pub coverage: &'a [WindowCoverage],
10125 pub subscriptions: Vec<String>,
10126 pub commit_ids: Vec<String>,
10127}
10128
10129#[cfg(test)]
10130thread_local! {
10131 static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
10135 std::cell::RefCell::new(None);
10136}
10137
10138#[cfg(test)]
10139fn run_snapshot_read_hook() {
10140 SNAPSHOT_READ_HOOK.with(|slot| {
10141 if let Some(hook) = slot.borrow_mut().take() {
10142 hook();
10143 }
10144 });
10145}
10146
10147fn snapshot_read_connection(
10152 conn: &Connection,
10153 request: &SnapshotReadRequest<'_>,
10154) -> Result<SnapshotRead, QueryReadFailure> {
10155 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
10156 let corrupt = |message: &str| QueryReadFailure {
10157 code: Some("sync.local_corrupt"),
10158 sqlite_code: None,
10159 sqlite_message: None,
10160 rollback_failure: None,
10161 message: message.to_owned(),
10162 };
10163 let stored_failure = |error: rusqlite::Error, message: &str| match error {
10164 rusqlite::Error::Utf8Error(..)
10165 | rusqlite::Error::InvalidColumnType(..)
10166 | rusqlite::Error::FromSqlConversionFailure(..)
10167 | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
10168 _ => QueryReadFailure::from(error),
10169 };
10170 let result: Result<SnapshotRead, QueryReadFailure> = (|| {
10171 let revision: Option<String> = conn
10174 .query_row(
10175 "SELECT value FROM _syncular_meta WHERE key = ?1",
10176 rusqlite::params![LOCAL_REVISION_KEY],
10177 |row| row.get(0),
10178 )
10179 .optional()
10180 .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
10181 let revision = match revision {
10182 None => "0".to_owned(),
10183 Some(value)
10184 if value == "0"
10185 || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
10186 && value.bytes().all(|b| b.is_ascii_digit())
10187 && value.parse::<u64>().is_ok()) =>
10188 {
10189 value
10190 }
10191 Some(_) => return Err(corrupt("persisted local revision is invalid")),
10192 };
10193 let mut queries = Vec::with_capacity(request.statements.len());
10194 for statement in &request.statements {
10195 queries.push(query_connection(conn, statement.sql, statement.params)?);
10196 #[cfg(test)]
10197 run_snapshot_read_hook();
10198 }
10199 let mut pending = Vec::new();
10200 let mut missing = Vec::new();
10201 for requested in request.coverage {
10202 let base_key = window_base_key(&requested.base);
10203 let state = persisted_window_state(conn, &requested.base)?;
10204 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
10205 let reference = WindowUnitRef {
10206 base_key: base_key.clone(),
10207 unit: unit.clone(),
10208 };
10209 if !state.units.iter().any(|held| held == &unit) {
10210 missing.push(reference);
10211 } else if state.pending.iter().any(|held| held == &unit) {
10212 pending.push(reference);
10213 }
10214 }
10215 }
10216 let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
10217 for id in &request.subscriptions {
10218 let row: Option<(String, String)> = conn
10219 .query_row(
10220 "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
10221 rusqlite::params![id],
10222 |row| Ok((row.get(0)?, row.get(1)?)),
10223 )
10224 .optional()
10225 .map_err(|error| {
10226 stored_failure(error, "persisted subscription state is invalid")
10227 })?;
10228 let Some((table, raw)) = row else {
10229 subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
10230 continue;
10231 };
10232 let state: Value = serde_json::from_str(&raw)
10235 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
10236 let status = match state.get("status").and_then(Value::as_str) {
10237 Some(value @ ("active" | "revoked" | "failed")) => value,
10238 _ => return Err(corrupt("persisted subscription state is invalid")),
10239 };
10240 let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
10241 return Err(corrupt("persisted subscription state is invalid"));
10242 };
10243 if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
10244 return Err(corrupt("persisted subscription state is invalid"));
10245 }
10246 let effective_scopes = state
10247 .get("effectiveScopes")
10248 .filter(|value| !value.is_null());
10249 if let Some(scopes) = effective_scopes {
10250 json_to_scope_map(scopes)
10251 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
10252 }
10253 let has_resume_token = match state.get("bootstrapState") {
10254 None | Some(Value::Null) => false,
10255 Some(Value::String(_)) => true,
10256 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
10257 };
10258 let reason_code = match state.get("reasonCode") {
10261 None | Some(Value::Null) => None,
10262 Some(Value::String(value)) => Some(value.clone()),
10263 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
10264 };
10265 let active = status == "active";
10266 subscriptions.push(SubscriptionCatchup::Known {
10267 id: id.clone(),
10268 table,
10269 status: status.to_owned(),
10270 cursor,
10271 has_resume_token,
10272 bootstrap_complete: active && cursor >= 0 && !has_resume_token,
10273 known_pending_pages: active && (has_resume_token || cursor < 0),
10274 effective_scopes: effective_scopes.cloned(),
10275 reason_code,
10276 });
10277 }
10278 let mut deliveries = Vec::with_capacity(request.commit_ids.len());
10279 for id in &request.commit_ids {
10280 let pending: bool = conn.query_row(
10281 "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
10282 rusqlite::params![id],
10283 |row| row.get(0),
10284 )?;
10285 if pending {
10286 deliveries.push(CommitDelivery::Pending {
10287 client_commit_id: id.clone(),
10288 });
10289 continue;
10290 }
10291 let row = conn
10292 .query_row(
10293 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
10294 resolution, resolved_at_ms, replacement_client_commit_id
10295 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
10296 rusqlite::params![id],
10297 StoredCommitOutcomeRow::from_row,
10298 )
10299 .optional()
10300 .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
10301 deliveries.push(match row {
10302 Some(row) => CommitDelivery::Known {
10303 client_commit_id: id.clone(),
10304 outcome: SyncClient::outcome_from_row(row)
10305 .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
10306 },
10307 None => CommitDelivery::Unknown {
10308 client_commit_id: id.clone(),
10309 },
10310 });
10311 }
10312 Ok(SnapshotRead {
10313 revision,
10314 queries,
10315 coverage: CoverageSnapshot {
10316 complete: pending.is_empty() && missing.is_empty(),
10317 pending,
10318 missing,
10319 },
10320 subscriptions,
10321 deliveries,
10322 })
10323 })();
10324 match result {
10325 Ok(read) => {
10326 conn.execute_batch("RELEASE syncular_snapshot_read")?;
10327 Ok(read)
10328 }
10329 Err(mut error) => {
10330 if let Err(rollback) = conn
10331 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
10332 {
10333 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
10334 }
10335 Err(error)
10336 }
10337 }
10338}
10339
10340fn snapshot_connection(
10341 conn: &Connection,
10342 sql: &str,
10343 params: &[Value],
10344 coverage: &[WindowCoverage],
10345) -> Result<QuerySnapshot, QueryReadFailure> {
10346 let read = snapshot_read_connection(
10347 conn,
10348 &SnapshotReadRequest {
10349 statements: vec![SnapshotStatement { sql, params }],
10350 coverage,
10351 subscriptions: Vec::new(),
10352 commit_ids: Vec::new(),
10353 },
10354 )?;
10355 Ok(QuerySnapshot {
10356 revision: read.revision,
10357 rows: read
10358 .queries
10359 .into_iter()
10360 .next()
10361 .expect("single-query snapshot returns one result"),
10362 coverage: read.coverage,
10363 })
10364}
10365
10366pub struct FileQuerySnapshotReader {
10371 path: String,
10372 conn: Option<Connection>,
10373}
10374
10375impl FileQuerySnapshotReader {
10376 #[must_use]
10377 pub fn new(path: impl Into<String>) -> Self {
10378 Self {
10379 path: path.into(),
10380 conn: None,
10381 }
10382 }
10383
10384 fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
10385 if self.conn.is_none() {
10386 let conn = Connection::open_with_flags(
10387 &self.path,
10388 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
10389 )
10390 .map_err(QueryReadFailure::from)?;
10391 conn.busy_timeout(std::time::Duration::from_millis(250))
10392 .map_err(QueryReadFailure::from)?;
10393 self.conn = Some(conn);
10394 }
10395 self.conn
10396 .as_ref()
10397 .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
10398 }
10399
10400 pub fn snapshot_read(
10403 &mut self,
10404 request: &SnapshotReadRequest<'_>,
10405 ) -> Result<SnapshotRead, QueryReadFailure> {
10406 snapshot_read_connection(self.connection()?, request)
10407 }
10408
10409 pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
10412 Ok(self
10413 .snapshot_read(&SnapshotReadRequest::default())?
10414 .revision)
10415 }
10416
10417 pub fn subscription_catchup(
10419 &mut self,
10420 id: &str,
10421 ) -> Result<SubscriptionCatchup, QueryReadFailure> {
10422 let read = self.snapshot_read(&SnapshotReadRequest {
10423 subscriptions: vec![id.to_owned()],
10424 ..Default::default()
10425 })?;
10426 Ok(read
10427 .subscriptions
10428 .into_iter()
10429 .next()
10430 .expect("one requested catch-up"))
10431 }
10432
10433 pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
10436 let read = self.snapshot_read(&SnapshotReadRequest {
10437 commit_ids: vec![id.to_owned()],
10438 ..Default::default()
10439 })?;
10440 Ok(read
10441 .deliveries
10442 .into_iter()
10443 .next()
10444 .expect("one requested delivery"))
10445 }
10446
10447 pub fn query_snapshot(
10450 &mut self,
10451 sql: &str,
10452 params: &[Value],
10453 coverage: &[WindowCoverage],
10454 ) -> Result<QuerySnapshot, QueryReadFailure> {
10455 snapshot_connection(self.connection()?, sql, params, coverage)
10456 }
10457}
10458
10459fn validate_authority_reads(
10460 schema: &ClientSchema,
10461 declarations: &[crate::api::AuthorityReadDeclaration],
10462) -> Result<(), String> {
10463 let mut seen = BTreeSet::new();
10464 for read in declarations {
10465 let table = schema
10466 .table(&read.table)
10467 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
10468 let plain = |name: &str| {
10469 table.columns.iter().enumerate().any(|(i, c)| {
10470 c.name == name
10471 && name != "authority_version"
10472 && !name.starts_with("_sync")
10473 && !table.encrypted_columns.iter().any(|e| e.index == i)
10474 && matches!(
10475 c.ty,
10476 ssp2::segment::ColumnType::String
10477 | ssp2::segment::ColumnType::Integer
10478 | ssp2::segment::ColumnType::Float
10479 | ssp2::segment::ColumnType::Boolean
10480 | ssp2::segment::ColumnType::Json
10481 )
10482 })
10483 };
10484 if !seen.insert(&read.table)
10485 || read.columns.is_empty()
10486 || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
10487 || !read.columns.contains(&table.primary_key)
10488 || !read.columns.iter().all(|c| plain(c))
10489 || read.scopes.is_empty()
10490 || !read.scopes.iter().all(|(v, values)| {
10491 table
10492 .scope_column(v)
10493 .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
10494 && !values.is_empty()
10495 && values.iter().all(|v| !v.is_empty() && v != "*")
10496 })
10497 {
10498 return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
10499 }
10500 }
10501 Ok(())
10502}
10503
10504fn schema_marker_read_failure(error: rusqlite::Error) -> String {
10509 let failure = QueryReadFailure::from(error);
10510 if failure.code.is_some() {
10511 failure.to_string()
10512 } else {
10513 "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
10514 }
10515}
10516
10517fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
10521 if limits.outcome_retention_max_entries == Some(0) {
10522 return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
10523 }
10524 if [
10525 limits.max_push_commits_per_request,
10526 limits.max_push_operations_per_request,
10527 limits.max_push_request_bytes,
10528 ]
10529 .into_iter()
10530 .flatten()
10531 .any(|value| value == 0 || value > u32::MAX as usize)
10532 {
10533 return Err(
10534 "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
10535 );
10536 }
10537 Ok(())
10538}
10539
10540fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
10541 let corrupt =
10542 || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
10543 let table_type: Option<String> = conn
10544 .query_row(
10545 "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
10546 [],
10547 |row| row.get(0),
10548 )
10549 .optional()
10550 .map_err(schema_marker_read_failure)?;
10551 let Some(table_type) = table_type else {
10552 return Ok(None);
10553 };
10554 if table_type != "table" {
10555 return Err(corrupt());
10556 }
10557 let mut statement = conn
10561 .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
10562 .map_err(schema_marker_read_failure)?;
10563 let mut rows = statement
10564 .query([LOCAL_SCHEMA_VERSION_KEY])
10565 .map_err(schema_marker_read_failure)?;
10566 let value: Option<String> = rows
10567 .next()
10568 .map_err(schema_marker_read_failure)?
10569 .map(|row| row.get(0))
10570 .transpose()
10571 .map_err(schema_marker_read_failure)?;
10572 if rows.next().map_err(schema_marker_read_failure)?.is_some() {
10573 return Err(corrupt());
10574 }
10575 let Some(value) = value else {
10576 let has_descriptor = conn
10577 .query_row(
10578 "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
10579 [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
10580 |_| Ok(()),
10581 )
10582 .optional()
10583 .map_err(schema_marker_read_failure)?
10584 .is_some();
10585 if has_descriptor {
10586 return Err(corrupt());
10587 }
10588 return Ok(None);
10589 };
10590 if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
10591 || !value.bytes().all(|b| b.is_ascii_digit())
10592 {
10593 return Err(corrupt());
10594 }
10595 let version = value.parse::<i32>().map_err(|_| corrupt())?;
10596 if version > requested {
10597 return Err(
10598 "client.schema_downgrade: persisted local schema is newer than the requested schema"
10599 .to_owned(),
10600 );
10601 }
10602 Ok(Some(version))
10603}
10604
10605impl SyncClient {
10606 pub fn new_with_identity(
10607 client_id: Option<String>,
10608 schema_json: &Value,
10609 limits: ClientLimits,
10610 ) -> Result<Self, String> {
10611 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
10612 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
10613 Self::with_connection(resolved, schema_json, limits, conn)
10614 }
10615
10616 pub fn new(
10617 client_id: String,
10618 schema_json: &Value,
10619 limits: ClientLimits,
10620 ) -> Result<Self, String> {
10621 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
10622 Self::with_connection(client_id, schema_json, limits, conn)
10623 }
10624
10625 pub fn open_path(
10631 client_id: String,
10632 schema_json: &Value,
10633 limits: ClientLimits,
10634 path: &str,
10635 ) -> Result<Self, String> {
10636 validate_client_limits(&limits)?;
10637 parse_schema_json(schema_json)?;
10638 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
10639 Self::with_connection(client_id, schema_json, limits, conn)
10640 }
10641
10642 pub fn open_path_with_identity(
10643 client_id: Option<String>,
10644 schema_json: &Value,
10645 limits: ClientLimits,
10646 path: &str,
10647 ) -> Result<Self, String> {
10648 validate_client_limits(&limits)?;
10649 parse_schema_json(schema_json)?;
10652 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
10653 conn.busy_timeout(std::time::Duration::from_millis(250))
10661 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
10662 let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
10663 let journal_mode: String = client
10664 .conn
10665 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
10666 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10667 if journal_mode != "wal" {
10668 return Err(
10669 "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
10670 );
10671 }
10672 Ok(client)
10673 }
10674
10675 #[must_use]
10676 pub fn client_id(&self) -> &str {
10677 &self.client_id
10678 }
10679
10680 pub fn with_connection(
10687 client_id: String,
10688 schema_json: &Value,
10689 limits: ClientLimits,
10690 conn: Connection,
10691 ) -> Result<Self, String> {
10692 Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
10693 }
10694
10695 fn with_connection_identity(
10698 client_id: Option<String>,
10699 schema_json: &Value,
10700 limits: ClientLimits,
10701 conn: Connection,
10702 ) -> Result<Self, String> {
10703 validate_client_limits(&limits)?;
10704 let schema = parse_schema_json(schema_json)?;
10705 validate_authority_reads(&schema, &limits.authority_reads)?;
10706 let previous_version = match limits.previous_version_context {
10709 Some(config) => {
10710 config.validate()?;
10711 Some(config)
10712 }
10713 None => None,
10714 };
10715 let mut client = SyncClient {
10716 #[cfg(feature = "bench-internals")]
10717 benchmark_phases: Recorder::default(),
10718 conn,
10719 schema,
10720 client_id: client_id
10721 .clone()
10722 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
10723 limits,
10724 subs: Vec::new(),
10725 outbox: Vec::new(),
10726 failed_commits: Vec::new(),
10727 retain_failed_commits: false,
10728 conflicts: Vec::new(),
10729 rejections: Vec::new(),
10730 schema_floor: None,
10731 lease_state: None,
10732 stopped: false,
10733 upgrading: false,
10734 sync_needed: false,
10735 realtime_policy: RealtimePolicy::Optional,
10736 realtime_state: RealtimeState::Disconnected,
10737 realtime_reason_code: None,
10738 realtime_retry_delay_ms: None,
10739 presence: HashMap::new(),
10740 now_ms: None,
10741 encryption: crate::values::EncryptionConfig::default(),
10742 security_preflight: false,
10743 transport_enabled: true,
10744 previous_version,
10745 insert_sql: RefCell::new(HashMap::new()),
10746 overlay_dirty: OverlayDirty::clean(),
10747 storage_failure: RefCell::new(None),
10748 #[cfg(test)]
10749 overlay_rebuild_count: Cell::new(0),
10750 #[cfg(test)]
10751 acknowledged_replay_count: Cell::new(0),
10752 #[cfg(test)]
10753 outcome_prune_count: Cell::new(0),
10754 progress: ProgressObserver::default(),
10755 change_queue: VecDeque::new(),
10756 sync_intent_queue: VecDeque::new(),
10757 retry_delay_ms: 250,
10758 round_retry_delay_ms: None,
10759 active_round: None,
10760 last_round: None,
10761 last_change: None,
10762 query_failures: Vec::new(),
10763 coalesced: None,
10764 coalesced_failure: None,
10765 };
10766 client
10770 .conn
10771 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
10772 client
10778 .conn
10779 .execute_batch("BEGIN IMMEDIATE")
10780 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10781 let startup = (|| -> Result<(), String> {
10782 let marker = read_local_schema_version(&client.conn, client.schema.version)?;
10785 client.create_bookkeeping_tables()?;
10788 match client.get_meta(CLIENT_ID_KEY) {
10789 Some(existing) => match client_id {
10790 Some(requested) if existing != requested => {
10791 return Err(format!(
10792 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
10793 ))
10794 }
10795 _ => client.client_id = existing,
10796 },
10797 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
10798 }
10799 client.restore_persisted_state()?;
10800 client.failed_commits = client.load_failed_commits()?;
10801 match marker {
10802 None => {
10803 client.create_synced_tables()?;
10804 client.persist_schema_version()?;
10805 client.save_subscription_scope_schema();
10806 }
10807 Some(version) if version == client.schema.version => {
10808 client.create_synced_tables()?;
10809 set_local_schema_descriptor(&client.conn, &client.schema)
10813 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10814 client.save_subscription_scope_schema();
10815 }
10816 Some(_) => client.run_schema_reset()?,
10817 }
10818 client.reconcile_previous_version_at_boot()?;
10824 client.clear_satisfied_persisted_schema_floor();
10825 client.prune_unknown_subscriptions(false)?;
10826 client.drop_unencodable_outbox(false, None)?;
10835 if marker == Some(client.schema.version)
10836 && (!client.outbox.is_empty()
10837 || !client.failed_commits.is_empty()
10838 || client.has_acknowledged_rows())
10839 {
10840 client.overlay_dirty.set(true);
10844 client.rebuild_overlay()?;
10845 }
10846 client.enqueue_startup_sync_if_needed()?;
10853 Ok(())
10854 })();
10855 match startup {
10856 Ok(()) => match client.conn.execute_batch("COMMIT") {
10857 Ok(()) => Ok(client),
10858 Err(error) => {
10859 let failure = Self::sqlite_failure(&client.storage_failure, error);
10860 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
10861 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
10863 }
10864 Err(failure)
10865 }
10866 },
10867 Err(error) => {
10868 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
10869 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
10871 }
10872 Err(error)
10873 }
10874 }
10875 }
10876
10877 pub fn set_now_ms(&mut self, now_ms: i64) {
10880 self.now_ms = Some(now_ms);
10881 }
10882
10883 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
10887 self.encryption = encryption;
10888 }
10889
10890 #[must_use]
10891 pub fn security_lifecycle(&self) -> &'static str {
10892 if self.security_preflight {
10893 "preflight"
10894 } else {
10895 "active"
10896 }
10897 }
10898
10899 #[must_use]
10900 pub fn security_preflight(&self) -> bool {
10901 self.security_preflight
10902 }
10903
10904 pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
10906 if self.transport_enabled != enabled {
10907 self.transport_enabled = enabled;
10908 self.sync_intent_queue.clear();
10909 self.sync_intent_queue
10910 .push_back(if enabled && !self.security_preflight {
10911 SyncIntent::Interactive
10912 } else {
10913 SyncIntent::None
10914 });
10915 }
10916 if !enabled && self.active_round.is_none() {
10918 self.disconnect_realtime(transport);
10919 }
10920 }
10921
10922 pub fn transport_enabled(&self) -> bool {
10923 self.transport_enabled
10924 }
10925
10926 pub fn begin_security_preflight(&mut self) {
10933 self.seal_security_on_teardown();
10934 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
10937 }
10938
10939 pub fn seal_security_on_teardown(&mut self) {
10949 self.cancel_sync_round();
10950 self.security_preflight = true;
10951 self.encryption = crate::values::EncryptionConfig::default();
10952 self.sync_intent_queue.clear();
10953 }
10954
10955 pub fn activate_security(
10957 &mut self,
10958 encryption: crate::values::EncryptionConfig,
10959 ) -> Result<(), String> {
10960 if !self.security_preflight {
10961 return Err(
10962 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
10963 );
10964 }
10965 self.enqueue_startup_sync_if_needed()?;
10966 self.encryption = encryption;
10967 self.security_preflight = false;
10968 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
10969 Ok(())
10970 }
10971
10972 fn clock_now_ms(&self) -> i64 {
10973 self.now_ms.unwrap_or_else(crate::wall_clock_ms)
10974 }
10975
10976 fn create_bookkeeping_tables(&self) -> Result<(), String> {
10977 self.conn
10979 .execute_batch(
10980 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
10981 seq INTEGER PRIMARY KEY AUTOINCREMENT,
10982 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
10983 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
10984 tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
10985 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
10986 commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
10987 commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
10988 PRIMARY KEY(commit_id, idx));
10989 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
10990 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
10991 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
10992 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
10993 seq INTEGER PRIMARY KEY AUTOINCREMENT,
10994 client_commit_id TEXT NOT NULL UNIQUE,
10995 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
10996 recorded_at_ms INTEGER NOT NULL,
10997 results_json TEXT NOT NULL,
10998 operations_json TEXT,
10999 resolution TEXT NOT NULL DEFAULT 'active'
11000 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
11001 resolved_at_ms INTEGER,
11002 replacement_client_commit_id TEXT);
11003 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
11004 ON _syncular_commit_outcomes(resolution, seq);
11005 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
11006 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
11007 CREATE TABLE IF NOT EXISTS _syncular_meta (
11008 key TEXT PRIMARY KEY, value TEXT NOT NULL);
11009 CREATE TABLE IF NOT EXISTS _syncular_windows (
11010 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
11011 PRIMARY KEY (base, unit));
11012 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
11013 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
11014 effective_scopes TEXT NOT NULL);
11015 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
11016 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
11017 PRIMARY KEY(commit_id, blob_id));
11018 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
11019 ON _syncular_blob_commit_refs(blob_id);",
11020 )
11021 .map_err(|e| e.to_string())?;
11022 let _ = self
11025 .conn
11026 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
11027 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
11028 self.set_meta(LOCAL_REVISION_KEY, "0");
11029 }
11030 if self.schema_has_blobs() {
11033 let exists = self
11034 .conn
11035 .query_row(
11036 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
11037 [],
11038 |row| row.get::<_, bool>(0),
11039 )
11040 .map_err(|e| e.to_string())?;
11041 if exists {
11042 let mut statement = self
11043 .conn
11044 .prepare("PRAGMA table_info(_syncular_blobs)")
11045 .map_err(|e| e.to_string())?;
11046 let mut columns = statement
11047 .query_map([], |row| row.get::<_, String>(1))
11048 .map_err(|e| e.to_string())?
11049 .collect::<Result<Vec<_>, _>>()
11050 .map_err(|e| e.to_string())?;
11051 columns.sort();
11052 let mut expected = vec![
11053 "blob_id",
11054 "byte_length",
11055 "bytes",
11056 "created_at_ms",
11057 "media_type",
11058 ];
11059 expected.sort_unstable();
11060 if columns != expected {
11061 return Err(
11062 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
11063 );
11064 }
11065 } else {
11066 self.conn
11067 .execute_batch(
11068 "CREATE TABLE _syncular_blobs(
11069 blob_id TEXT PRIMARY KEY,
11070 bytes BLOB NOT NULL,
11071 byte_length INTEGER NOT NULL,
11072 media_type TEXT,
11073 created_at_ms INTEGER NOT NULL);",
11074 )
11075 .map_err(|e| e.to_string())?;
11076 }
11077 self.conn
11078 .execute_batch(
11079 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
11080 blob_id TEXT PRIMARY KEY,
11081 media_type TEXT,
11082 created_at_ms INTEGER NOT NULL);",
11083 )
11084 .map_err(|e| e.to_string())?;
11085 }
11086 Ok(())
11087 }
11088
11089 fn schema_has_blobs(&self) -> bool {
11091 self.schema
11092 .tables
11093 .iter()
11094 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
11095 }
11096
11097 fn get_meta(&self, key: &str) -> Option<String> {
11100 meta_get(&self.conn, key)
11101 }
11102
11103 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
11104 self.conn
11105 .query_row(
11106 "SELECT value FROM _syncular_meta WHERE key = ?1",
11107 rusqlite::params![key],
11108 |row| row.get::<_, String>(0),
11109 )
11110 .optional()
11111 .map_err(|_| {
11112 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
11113 })
11114 }
11115
11116 fn persist_schema_version(&self) -> Result<(), String> {
11119 self.conn
11120 .execute(
11121 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
11122 rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
11123 )
11124 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11125 set_local_schema_descriptor(&self.conn, &self.schema)
11126 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11127 }
11128
11129 fn set_meta(&self, key: &str, value: &str) {
11130 meta_set(&self.conn, key, value);
11131 }
11132
11133 fn delete_meta(&self, key: &str) {
11134 meta_delete(&self.conn, key);
11135 }
11136
11137 fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
11139 let mut stmt = self
11140 .conn
11141 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
11142 .map_err(|error| error.to_string())?;
11143 let rows = stmt
11144 .query_map([], |row| {
11145 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11146 })
11147 .map_err(|error| error.to_string())?;
11148 let mut commits = Vec::new();
11149 for row in rows {
11150 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
11151 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
11152 format!("invalid persisted outbox {client_commit_id:?}: {error}")
11153 })?;
11154 let mut ops = Vec::with_capacity(entries.len());
11155 for entry in entries {
11156 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
11157 ops.push(OutboxOp {
11158 upsert: op == "upsert",
11159 table: entry
11160 .get("table")
11161 .and_then(Value::as_str)
11162 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
11163 .to_owned(),
11164 row_id: entry
11165 .get("rowId")
11166 .and_then(Value::as_str)
11167 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
11168 .to_owned(),
11169 base_version: entry.get("baseVersion").and_then(Value::as_i64),
11170 values: entry.get("values").and_then(Value::as_object).cloned(),
11173 });
11174 }
11175 commits.push(OutboxCommit {
11176 client_commit_id,
11177 ops,
11178 });
11179 }
11180 Ok(commits)
11181 }
11182
11183 fn restore_persisted_state(&mut self) -> Result<(), String> {
11184 self.subs = {
11185 let mut stmt = self
11186 .conn
11187 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
11188 .map_err(|error| error.to_string())?;
11189 let rows = stmt
11190 .query_map([], |row| {
11191 Ok((
11192 row.get::<_, String>(0)?,
11193 row.get::<_, String>(1)?,
11194 row.get::<_, String>(2)?,
11195 ))
11196 })
11197 .map_err(|error| error.to_string())?;
11198 let mut subscriptions = Vec::new();
11199 for row in rows {
11200 let (id, table, raw) = row.map_err(|error| error.to_string())?;
11201 let state: Value = serde_json::from_str(&raw)
11202 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
11203 let requested = json_to_scope_map(
11204 state.get("requested").unwrap_or(&Value::Object(Map::new())),
11205 )?;
11206 let effective = state
11207 .get("effectiveScopes")
11208 .filter(|value| !value.is_null())
11209 .map(json_to_scope_map)
11210 .transpose()?;
11211 subscriptions.push(Subscription {
11212 id,
11213 table,
11214 requested,
11215 params: state
11216 .get("params")
11217 .and_then(Value::as_str)
11218 .map(str::to_owned),
11219 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
11220 bootstrap_state: state
11221 .get("bootstrapState")
11222 .and_then(Value::as_str)
11223 .map(str::to_owned),
11224 state: SubState::parse(
11225 state
11226 .get("status")
11227 .and_then(Value::as_str)
11228 .unwrap_or("active"),
11229 ),
11230 reason_code: state
11231 .get("reasonCode")
11232 .and_then(Value::as_str)
11233 .map(str::to_owned),
11234 effective,
11235 synced_once: state
11236 .get("syncedOnce")
11237 .and_then(Value::as_bool)
11238 .unwrap_or(false),
11239 });
11240 }
11241 subscriptions
11242 };
11243
11244 self.outbox = self.load_outbox()?;
11245
11246 self.prune_commit_outcomes()?;
11247 let active = self.commit_outcomes(CommitOutcomeQuery {
11248 active_only: true,
11249 ..CommitOutcomeQuery::default()
11250 })?;
11251 self.conflicts = active
11252 .iter()
11253 .flat_map(|outcome| outcome.results.iter())
11254 .filter_map(|result| match result {
11255 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
11256 _ => None,
11257 })
11258 .collect();
11259 self.rejections = active
11260 .iter()
11261 .flat_map(|outcome| outcome.results.iter())
11262 .filter_map(|result| match result {
11263 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
11264 _ => None,
11265 })
11266 .collect();
11267
11268 self.lease_state = self
11269 .get_meta(LEASE_STATE_KEY)
11270 .map(|raw| serde_json::from_str(&raw))
11271 .transpose()
11272 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
11273 self.schema_floor = self
11274 .get_meta(SCHEMA_FLOOR_KEY)
11275 .map(|raw| serde_json::from_str(&raw))
11276 .transpose()
11277 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
11278 self.stopped = self.schema_floor.is_some();
11279 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
11282 self.security_preflight = true;
11283 }
11284 Ok(())
11285 }
11286
11287 fn save_subscription_scope_schema(&self) {
11288 let declarations: BTreeMap<_, BTreeMap<_, _>> = self
11289 .schema
11290 .tables
11291 .iter()
11292 .map(|table| {
11293 (
11294 table.name.clone(),
11295 table
11296 .scope_variables
11297 .iter()
11298 .map(|scope| {
11299 (
11300 scope.variable.clone(),
11301 [scope.prefix.clone(), scope.column.clone()],
11302 )
11303 })
11304 .collect(),
11305 )
11306 })
11307 .collect();
11308 self.set_meta(
11309 "subscriptionScopeSchema",
11310 &serde_json::to_string(&declarations).expect("scope schema serialization"),
11311 );
11312 }
11313
11314 fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
11316 let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
11317 self.get_meta("subscriptionScopeSchema")
11318 .map(|raw| serde_json::from_str(&raw))
11319 .transpose()
11320 .map_err(|error| format!("invalid persisted scope schema: {error}"))?
11321 } else {
11322 None
11323 };
11324 let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
11325 let Some(table) = self.schema.table(table_name) else {
11326 return false;
11327 };
11328 if requested
11329 .iter()
11330 .any(|(variable, _)| table.scope_column(variable).is_none())
11331 {
11332 return false;
11333 }
11334 if !bump {
11335 return true;
11336 }
11337 let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
11338 return false;
11339 };
11340 let variables: BTreeSet<_> = if requested.is_empty() {
11341 old.keys()
11342 .cloned()
11343 .chain(
11344 table
11345 .scope_variables
11346 .iter()
11347 .map(|scope| scope.variable.clone()),
11348 )
11349 .collect()
11350 } else {
11351 requested
11352 .iter()
11353 .map(|(variable, _)| variable.clone())
11354 .collect()
11355 };
11356 variables.iter().all(|variable| {
11357 let current = table
11358 .scope_variables
11359 .iter()
11360 .find(|scope| &scope.variable == variable);
11361 match (old.get(variable), current) {
11362 (Some(old), Some(current)) => {
11363 old == &[current.prefix.clone(), current.column.clone()]
11364 }
11365 _ => false,
11366 }
11367 })
11368 };
11369 let mut stale_ids: BTreeSet<_> = self
11370 .subs
11371 .iter()
11372 .filter(|sub| !compatible(&sub.table, &sub.requested))
11373 .map(|sub| sub.id.clone())
11374 .collect();
11375 for (id, table, effective) in self.load_pending_evictions()? {
11376 if !compatible(&table, &effective) {
11377 stale_ids.insert(id);
11378 }
11379 }
11380 for id in &stale_ids {
11381 for sql in [
11382 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
11383 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
11384 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
11385 ] {
11386 self.conn
11387 .execute(sql, rusqlite::params![id])
11388 .map_err(|error| error.to_string())?;
11389 }
11390 }
11391 self.subs.retain(|sub| !stale_ids.contains(&sub.id));
11392 Ok(())
11393 }
11394
11395 #[must_use]
11396 pub fn local_revision(&self) -> u64 {
11397 self.get_meta(LOCAL_REVISION_KEY)
11398 .and_then(|value| value.parse().ok())
11399 .unwrap_or(0)
11400 }
11401
11402 pub fn snapshot_read(
11408 &mut self,
11409 request: &SnapshotReadRequest<'_>,
11410 owner: Option<QueryOwner<'_>>,
11411 ) -> Result<SnapshotRead, QueryReadFailure> {
11412 let result = snapshot_read_connection(&self.conn, request);
11413 if let Some(owner) = owner {
11414 self.record_query_read(owner, result.as_ref().err());
11415 }
11416 result
11417 }
11418
11419 #[must_use]
11420 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
11421 SyncStatusSnapshot {
11422 current_schema_version: self.schema.version,
11423 outbox: self.outbox.len(),
11424 upgrading: self.upgrading,
11425 lease_state: self.lease_state.clone(),
11426 schema_floor: self.schema_floor.clone(),
11427 sync_needed: self.sync_needed,
11428 previous_version_context: self.previous_version_status(),
11429 }
11430 }
11431
11432 fn previous_version_status(&self) -> PreviousVersionStatus {
11435 let Some(replica_path) = self.previous_version_replica_path() else {
11436 return PreviousVersionStatus {
11437 present: false,
11438 created_at_ms: None,
11439 };
11440 };
11441 match crate::previous_version::read_previous_version_record(&replica_path) {
11442 Ok(Some(record)) => PreviousVersionStatus {
11443 present: true,
11444 created_at_ms: Some(record.created_at_ms),
11445 },
11446 _ => PreviousVersionStatus {
11447 present: std::path::Path::new(
11448 &crate::previous_version::previous_version_container_path(&replica_path),
11449 )
11450 .exists(),
11451 created_at_ms: None,
11452 },
11453 }
11454 }
11455
11456 pub fn previous_version_snapshot(
11462 &self,
11463 spec: &PreviousVersionReadSpec,
11464 ) -> Result<PreviousVersionSnapshot, String> {
11465 let now_ms = self.clock_now_ms();
11466 crate::previous_version::previous_version_snapshot(
11467 &self.conn,
11468 self.previous_version_replica_path().as_deref(),
11469 self.previous_version.as_ref(),
11470 PreviousVersionLifecycle {
11471 lease_inactive: self.previous_version_lease_inactive(now_ms),
11472 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
11473 coverage_complete: self.previous_version_coverage_complete(),
11474 },
11475 now_ms,
11476 self.schema.version,
11477 spec,
11478 )
11479 }
11480
11481 #[must_use]
11483 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
11484 crate::previous_version::stored_previous_version_audit(&self.conn)
11485 }
11486
11487 pub fn previous_version_discard(
11491 &self,
11492 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
11493 self.drop_previous_version()
11494 }
11495
11496 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
11500 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
11501 present: false,
11502 discarded: false,
11503 };
11504 let Some(replica_path) = self.previous_version_replica_path() else {
11505 return absent;
11506 };
11507 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
11508 .unwrap_or(absent)
11509 }
11510
11511 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
11513 let Some(lease) = &self.lease_state else {
11514 return false;
11515 };
11516 if lease.error_code.is_some() {
11517 return true;
11518 }
11519 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
11520 }
11521
11522 fn previous_version_coverage_complete(&self) -> bool {
11526 let active = self
11527 .subs
11528 .iter()
11529 .filter(|sub| sub.state == SubState::Active)
11530 .collect::<Vec<_>>();
11531 if active.is_empty() {
11532 return false;
11533 }
11534 active
11535 .iter()
11536 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
11537 }
11538
11539 fn previous_version_lifetime_check(&self) {
11543 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
11544 return;
11545 };
11546 let now_ms = self.clock_now_ms();
11547 if self.previous_version_lease_inactive(now_ms) {
11548 let _ = self.drop_previous_version();
11549 return;
11550 }
11551 let Some(replica_path) = self.previous_version_replica_path() else {
11552 return;
11553 };
11554 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
11555 else {
11556 return;
11557 };
11558 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
11559 let _ = self.drop_previous_version();
11560 return;
11561 }
11562 if now_ms - record.created_at_ms > config.max_age_ms {
11563 let _ = self.drop_previous_version();
11564 return;
11565 }
11566 if self.previous_version_coverage_complete() {
11567 let _ = self.drop_previous_version();
11568 }
11569 }
11570
11571 pub fn diagnostics_snapshot(
11572 &self,
11573 request: &ClientDiagnosticsRequest,
11574 ) -> Result<ClientDiagnosticsSnapshot, String> {
11575 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
11576 return Err(format!(
11577 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
11578 ));
11579 }
11580 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
11581 for sub in &self.subs {
11582 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
11583 let complete =
11584 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
11585 let state = match sub.state {
11586 SubState::Revoked => "revoked",
11587 SubState::Failed => "failed",
11588 SubState::Active if reset => "reset",
11589 SubState::Active if complete => "complete",
11590 SubState::Active => "bootstrapping",
11591 };
11592 subscriptions.insert(
11593 sub.id.clone(),
11594 DiagnosticSubscription {
11595 id: sub.id.clone(),
11596 table: sub.table.clone(),
11597 state: state.to_owned(),
11598 complete,
11599 cursor: Some(sub.cursor),
11600 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
11601 },
11602 );
11603 }
11604 for expected in &request.expected_subscriptions {
11605 if expected.id.is_empty() || expected.table.is_empty() {
11606 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
11607 }
11608 if subscriptions
11609 .get(&expected.id)
11610 .is_some_and(|registered| registered.table != expected.table)
11611 {
11612 subscriptions.insert(
11613 expected.id.clone(),
11614 DiagnosticSubscription {
11615 id: expected.id.clone(),
11616 table: expected.table.clone(),
11617 state: "failed".to_owned(),
11618 complete: false,
11619 cursor: None,
11620 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
11621 },
11622 );
11623 } else {
11624 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
11625 DiagnosticSubscription {
11626 id: expected.id.clone(),
11627 table: expected.table.clone(),
11628 state: "unregistered".to_owned(),
11629 complete: false,
11630 cursor: None,
11631 reason_code: None,
11632 }
11633 });
11634 }
11635 }
11636 let mut ordered_subscriptions = Vec::new();
11637 let mut included = BTreeSet::new();
11638 for expected in &request.expected_subscriptions {
11639 if included.insert(expected.id.clone()) {
11640 if let Some(subscription) = subscriptions.get(&expected.id) {
11641 ordered_subscriptions.push(subscription.clone());
11642 }
11643 }
11644 }
11645 for (id, subscription) in &subscriptions {
11646 if included.insert(id.clone()) {
11647 ordered_subscriptions.push(subscription.clone());
11648 }
11649 }
11650 let subscriptions_truncated =
11651 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
11652 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
11653 let captured_at_ms = self.clock_now_ms();
11654 let lease = if let Some(error_code) = self
11655 .lease_state
11656 .as_ref()
11657 .and_then(|state| state.error_code.clone())
11658 {
11659 ClientDiagnosticsLease {
11660 state: "stopped".to_owned(),
11661 expires_at_ms: self
11662 .lease_state
11663 .as_ref()
11664 .and_then(|state| state.expires_at_ms),
11665 error_code: Some(Self::diagnostic_code(&error_code)),
11666 }
11667 } else if let Some(expires_at_ms) = self
11668 .lease_state
11669 .as_ref()
11670 .and_then(|state| state.expires_at_ms)
11671 {
11672 ClientDiagnosticsLease {
11673 state: if expires_at_ms <= captured_at_ms {
11674 "expired".to_owned()
11675 } else {
11676 "active".to_owned()
11677 },
11678 expires_at_ms: Some(expires_at_ms),
11679 error_code: None,
11680 }
11681 } else {
11682 ClientDiagnosticsLease {
11683 state: "none".to_owned(),
11684 expires_at_ms: None,
11685 error_code: None,
11686 }
11687 };
11688 let connectivity = match self.last_round.as_ref() {
11689 _ if !self.transport_enabled && self.active_round.is_none() => "offline",
11690 Some(round) if round.status == "succeeded" => "online",
11691 Some(round)
11692 if round.status == "failed"
11693 && round
11694 .error_code
11695 .as_deref()
11696 .is_some_and(Self::retryable_transport_code) =>
11697 {
11698 "offline"
11699 }
11700 _ => "unknown",
11701 };
11702 Ok(ClientDiagnosticsSnapshot {
11703 version: CLIENT_DIAGNOSTICS_VERSION,
11704 captured_at_ms,
11705 host: ClientDiagnosticsHost {
11706 kind: "direct".to_owned(),
11707 role: "single".to_owned(),
11708 connectivity: connectivity.to_owned(),
11709 realtime: self.realtime_state,
11710 realtime_policy: self.realtime_policy,
11711 realtime_reason_code: self.realtime_reason_code.clone(),
11712 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
11713 },
11714 security_lifecycle: self.security_lifecycle().to_owned(),
11715 schema: ClientDiagnosticsSchema {
11716 current_version: self.schema.version,
11717 upgrading: self.upgrading,
11718 required_version: self
11719 .schema_floor
11720 .as_ref()
11721 .and_then(|floor| floor.required_schema_version),
11722 latest_version: self
11723 .schema_floor
11724 .as_ref()
11725 .and_then(|floor| floor.latest_schema_version),
11726 },
11727 replica: ClientDiagnosticsReplica {
11728 local_revision: self.local_revision().to_string(),
11729 sync_needed: self.sync_needed,
11730 pending_outbox: self.outbox.len(),
11731 },
11732 lease,
11733 subscriptions: ordered_subscriptions,
11734 subscriptions_truncated,
11735 last_round: self.last_round.clone(),
11736 last_change: self.last_change.clone(),
11737 storage: self.diagnostics_storage(),
11738 query_failures: self.query_failures.clone(),
11739 })
11740 }
11741
11742 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
11743 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
11744 let page_count: i64 = self
11745 .conn
11746 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
11747 let page_size: i64 = self
11748 .conn
11749 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
11750 let outbox_bytes: i64 = self
11752 .conn
11753 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
11754 .query_row([], |row| row.get(0))?;
11755 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
11756 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
11757 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
11758 let blob_bytes = if self.schema_has_blobs() {
11759 self.conn
11760 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
11761 .query_row([], |row| row.get(0))?
11762 } else {
11763 0
11764 };
11765 let pressure = self
11766 .limits
11767 .blob_cache_max_bytes
11768 .is_some_and(|limit| blob_bytes > limit);
11769 Ok(ClientDiagnosticsStorage {
11770 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
11771 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
11772 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
11773 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
11774 retained_outcome_entries: Some(outcome_entries.max(0)),
11775 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
11776 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
11777 })
11778 };
11779 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
11780 status: "unreadable".to_owned(),
11781 database_bytes_approx: None,
11782 pending_outbox_bytes_approx: None,
11783 retained_outcome_bytes_approx: None,
11784 retained_outcome_entries: None,
11785 blob_cache_bytes_approx: None,
11786 pressure_reason_code: None,
11787 })
11788 }
11789
11790 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
11791 self.change_queue.drain(..).collect()
11792 }
11793
11794 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
11795 let intents = self.sync_intent_queue.drain(..).collect();
11796 if self.transport_enabled {
11797 intents
11798 } else {
11799 vec![SyncIntent::None]
11800 }
11801 }
11802
11803 fn schedule_background_retry(&mut self) -> u64 {
11804 let delay_ms = self.retry_delay_ms;
11805 self.sync_intent_queue
11806 .push_back(SyncIntent::Background { delay_ms });
11807 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
11808 self.round_retry_delay_ms = Some(delay_ms);
11809 delay_ms
11810 }
11811
11812 fn reset_background_retry(&mut self) {
11813 self.retry_delay_ms = 250;
11814 }
11815
11816 fn retryable_transport_code(code: &str) -> bool {
11817 code == "transport.failed"
11818 || code == "transport.unavailable"
11819 || code == "sync.transport_failed"
11820 }
11821
11822 fn retryable_failure_code(code: &str) -> bool {
11827 Self::retryable_transport_code(code)
11828 || matches!(
11829 code,
11830 "sync.auth_required"
11831 | "sync.auth_lease_required"
11832 | "sync.auth_lease_revoked"
11833 | "sync.internal_error"
11834 | "sync.idempotency_cache_miss"
11835 | "sync.schema_not_ready"
11836 | "sync.segment_expired"
11837 | "sync.rate_limited"
11838 | "sync.websocket_connection_limit"
11839 )
11840 }
11841
11842 fn diagnostic_code(code: &str) -> String {
11843 let valid = !code.is_empty()
11844 && code.len() <= 96
11845 && code.contains('.')
11846 && code
11847 .bytes()
11848 .next()
11849 .is_some_and(|byte| byte.is_ascii_lowercase())
11850 && code.bytes().all(|byte| {
11851 byte.is_ascii_lowercase()
11852 || byte.is_ascii_digit()
11853 || matches!(byte, b'.' | b'_' | b'-')
11854 });
11855 if valid {
11856 code.to_owned()
11857 } else {
11858 "client.unknown_failure".to_owned()
11859 }
11860 }
11861
11862 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
11863 if self.sync_needed != value {
11864 if self.begin_observation("syncular_status").is_ok() {
11865 self.sync_needed = value;
11866 let batch = ChangeAccumulator {
11867 status: true,
11868 ..ChangeAccumulator::default()
11869 };
11870 if self.finish_observation("syncular_status", batch).is_err() {
11871 self.rollback_observation("syncular_status");
11872 }
11873 } else {
11874 self.sync_needed = value;
11875 }
11876 }
11877 if value && interactive {
11878 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11879 }
11880 }
11881
11882 fn sqlite_failure(
11883 storage_failure: &RefCell<Option<QueryReadFailure>>,
11884 error: rusqlite::Error,
11885 ) -> String {
11886 let message = error.to_string();
11887 let failure = QueryReadFailure::from(error);
11888 let code = failure.code;
11889 if code.is_some() && storage_failure.borrow().is_none() {
11890 *storage_failure.borrow_mut() = Some(failure);
11891 }
11892 match code {
11893 Some(code) => format!("{code}: {message}"),
11894 None => message,
11895 }
11896 }
11897
11898 fn begin_observation(&self, name: &str) -> Result<(), String> {
11899 if let Some(failure) = &self.coalesced_failure {
11900 return Err(failure.clone());
11901 }
11902 if self.coalesced.is_some() && self.conn.is_autocommit() {
11905 return Err("the coalesced apply transaction was rolled back".to_owned());
11906 }
11907 self.conn
11908 .execute_batch(&format!("SAVEPOINT {name}"))
11909 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11910 }
11911
11912 fn begin_coalesced_apply(&mut self) -> Result<(), String> {
11915 if self.coalesced.is_some() || !self.conn.is_autocommit() {
11916 return Ok(());
11917 }
11918 self.conn
11919 .execute_batch("BEGIN")
11920 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11921 self.coalesced = Some(CoalescedApply {
11922 rows: 0,
11923 subs: self.subs.clone(),
11924 outbox: self.outbox.clone(),
11925 failed_commits: self.failed_commits.clone(),
11926 conflicts: self.conflicts.clone(),
11927 rejections: self.rejections.clone(),
11928 schema_floor: self.schema_floor.clone(),
11929 lease_state: self.lease_state.clone(),
11930 stopped: self.stopped,
11931 upgrading: self.upgrading,
11932 sync_needed: self.sync_needed,
11933 overlay_dirty: self.overlay_dirty.snapshot(),
11934 change_queue: self.change_queue.len(),
11935 sync_intent_queue: self.sync_intent_queue.len(),
11936 last_change: self.last_change.clone(),
11937 });
11938 Ok(())
11939 }
11940
11941 fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
11944 let Some(open) = &self.coalesced else {
11945 return Ok(());
11946 };
11947 if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
11948 self.commit_coalesced_apply()?;
11949 self.begin_coalesced_apply()?;
11950 }
11951 if let Some(open) = &mut self.coalesced {
11952 open.rows += rows;
11953 }
11954 Ok(())
11955 }
11956
11957 fn commit_coalesced_apply(&mut self) -> Result<(), String> {
11962 let Some(open) = self.coalesced.take() else {
11963 return Ok(());
11964 };
11965 let committed = if self.conn.is_autocommit() {
11966 Err("the coalesced apply transaction was rolled back".to_owned())
11967 } else {
11968 self.conn
11969 .execute_batch("COMMIT")
11970 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11971 };
11972 let Err(error) = committed else {
11973 return Ok(());
11974 };
11975 if !self.conn.is_autocommit() {
11976 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
11977 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
11978 }
11979 }
11980 self.subs = open.subs;
11981 self.outbox = open.outbox;
11982 self.failed_commits = open.failed_commits;
11983 self.conflicts = open.conflicts;
11984 self.rejections = open.rejections;
11985 self.schema_floor = open.schema_floor;
11986 self.lease_state = open.lease_state;
11987 self.stopped = open.stopped;
11988 self.upgrading = open.upgrading;
11989 self.sync_needed = open.sync_needed;
11990 self.overlay_dirty.restore(open.overlay_dirty);
11991 self.change_queue.truncate(open.change_queue);
11992 self.sync_intent_queue.truncate(open.sync_intent_queue);
11993 self.last_change = open.last_change;
11994 self.coalesced_failure = Some(error.clone());
11995 Err(error)
11996 }
11997
11998 fn finish_coalesced_apply(&mut self) -> Result<(), String> {
12000 let committed = self.commit_coalesced_apply();
12001 match self.coalesced_failure.take() {
12002 Some(failure) => Err(failure),
12003 None => committed,
12004 }
12005 }
12006
12007 fn rollback_observation(&self, name: &str) {
12008 if let Err(error) = self
12009 .conn
12010 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
12011 {
12012 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
12013 if failure.rollback_failure.is_none() {
12014 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
12015 }
12016 }
12017 }
12018 }
12019
12020 fn finish_observation(
12021 &mut self,
12022 name: &str,
12023 mut batch: ChangeAccumulator,
12024 ) -> Result<(), String> {
12025 #[cfg(feature = "bench-internals")]
12026 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
12027 if self.failed_commits.iter().any(|(commit, _)| {
12028 commit
12029 .ops
12030 .iter()
12031 .any(|operation| batch.tables.contains_key(&operation.table))
12032 }) {
12033 batch.outcomes = true;
12034 }
12035 if !batch.touched() {
12036 self.conn
12037 .execute_batch(&format!("RELEASE {name}"))
12038 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12039 return Ok(());
12040 }
12041 let revision = self
12042 .local_revision()
12043 .checked_add(1)
12044 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
12045 self.conn
12046 .execute(
12047 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12048 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
12049 )
12050 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12051 let status = batch.status.then(|| self.status_snapshot());
12052 let event = ClientChangeBatch {
12053 revision: revision.to_string(),
12054 tables: batch
12055 .tables
12056 .into_iter()
12057 .map(|(table, scope_keys)| TableChange {
12058 table,
12059 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
12060 })
12061 .collect(),
12062 windows: batch
12063 .windows
12064 .into_iter()
12065 .map(|((base_key, table), units)| WindowChange {
12066 base_key,
12067 table,
12068 units: units.into_iter().collect(),
12069 })
12070 .collect(),
12071 status,
12072 conflicts_changed: batch.conflicts,
12073 rejections_changed: batch.rejections,
12074 outcomes_changed: batch.outcomes,
12075 };
12076 self.conn
12077 .execute_batch(&format!("RELEASE {name}"))
12078 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12079 let mut diagnostic_tables = event
12080 .tables
12081 .iter()
12082 .map(|entry| entry.table.clone())
12083 .collect::<BTreeSet<_>>()
12084 .into_iter()
12085 .collect::<Vec<_>>();
12086 let mut diagnostic_windows = event
12087 .windows
12088 .iter()
12089 .map(|entry| entry.table.clone())
12090 .collect::<BTreeSet<_>>()
12091 .into_iter()
12092 .collect::<Vec<_>>();
12093 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
12094 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
12095 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
12096 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
12097 self.last_change = Some(DiagnosticLastChange {
12098 revision: event.revision.clone(),
12099 recorded_at_ms: self.clock_now_ms(),
12100 tables: diagnostic_tables,
12101 windows: diagnostic_windows,
12102 domains_truncated,
12103 status_changed: event.status.is_some(),
12104 conflicts_changed: event.conflicts_changed,
12105 rejections_changed: event.rejections_changed,
12106 outcomes_changed: event.outcomes_changed,
12107 });
12108 self.change_queue.push_back(event);
12109 Ok(())
12110 }
12111
12112 fn record_scope_map(
12113 &self,
12114 batch: &mut ChangeAccumulator,
12115 table_name: &str,
12116 scopes: &[(String, Vec<String>)],
12117 ) {
12118 let Some(table) = self.schema.table(table_name) else {
12119 return;
12120 };
12121 for (variable, values) in scopes {
12122 let Some(scope) = table
12123 .scope_variables
12124 .iter()
12125 .find(|scope| &scope.variable == variable)
12126 else {
12127 continue;
12128 };
12129 for value in values {
12130 batch.scope(table_name, format!("{}:{value}", scope.prefix));
12131 }
12132 }
12133 }
12134
12135 fn record_row_scopes(
12137 &self,
12138 batch: &mut ChangeAccumulator,
12139 table_name: &str,
12140 row_id: &str,
12141 base: bool,
12142 ) -> bool {
12143 let Some(table) = self.schema.table(table_name) else {
12144 return false;
12145 };
12146 if table.scope_variables.is_empty() {
12147 return false;
12148 }
12149 let columns = table
12150 .scope_variables
12151 .iter()
12152 .map(|scope| quote_ident(&scope.column))
12153 .collect::<Vec<_>>()
12154 .join(", ");
12155 let full_table = if base {
12156 base_table(table_name)
12157 } else {
12158 visible_table(table_name)
12159 };
12160 let sql = format!(
12161 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
12162 row_id_predicate(table)
12163 );
12164 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
12165 return false;
12166 };
12167 let values = stmt.query_row(rusqlite::params![row_id], |row| {
12168 let mut values = Vec::with_capacity(table.scope_variables.len());
12169 for index in 0..table.scope_variables.len() {
12170 values.push(row.get::<_, Option<String>>(index)?);
12171 }
12172 Ok(values)
12173 });
12174 let Ok(values) = values else {
12175 return false;
12176 };
12177 let mut recorded = false;
12178 for (scope, value) in table.scope_variables.iter().zip(values) {
12179 if let Some(value) = value {
12180 batch.scope(table_name, format!("{}:{value}", scope.prefix));
12181 recorded = true;
12182 }
12183 }
12184 recorded
12185 }
12186
12187 fn record_commit_changes(
12188 &self,
12189 batch: &mut ChangeAccumulator,
12190 tables: &[String],
12191 changes: &[ssp2::model::Change],
12192 ) {
12193 #[cfg(feature = "bench-internals")]
12194 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
12195 for change in changes {
12196 let Some(table_name) = tables.get(change.table_index as usize) else {
12197 continue;
12198 };
12199 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
12200 if let Some(table) = self.schema.table(table_name) {
12201 for (variable, value) in &change.scopes {
12202 if let Some(scope) = table
12203 .scope_variables
12204 .iter()
12205 .find(|scope| &scope.variable == variable)
12206 {
12207 batch.scope(table_name, format!("{}:{value}", scope.prefix));
12208 precise = true;
12209 }
12210 }
12211 }
12212 if !precise {
12213 batch.table(table_name);
12214 }
12215 }
12216 }
12217
12218 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
12219 if effective.is_empty() {
12220 return false;
12221 }
12222 let Some(table) = self.schema.table(table_name) else {
12223 return false;
12224 };
12225 let mut clauses = Vec::new();
12226 let mut params = Vec::new();
12227 for (variable, values) in effective {
12228 let Some(column) = table.scope_column(variable) else {
12229 return false;
12230 };
12231 if values.is_empty() {
12232 return false;
12233 }
12234 let placeholders = values
12235 .iter()
12236 .map(|value| {
12237 params.push(SqlValue::Text(value.clone()));
12238 "?"
12239 })
12240 .collect::<Vec<_>>()
12241 .join(", ");
12242 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
12243 }
12244 let sql = format!(
12245 "SELECT 1 FROM {} WHERE {} LIMIT 1",
12246 base_table(table_name),
12247 clauses.join(" AND ")
12248 );
12249 self.conn
12250 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
12251 .is_ok()
12252 }
12253
12254 pub fn upgrading(&self) -> bool {
12256 self.upgrading
12257 }
12258
12259 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
12267 let new_schema = parse_schema_json(schema_json)?;
12268 validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
12269 self.conn
12270 .execute_batch("BEGIN IMMEDIATE")
12271 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12272 let prior_schema = std::mem::replace(&mut self.schema, new_schema);
12273 let prior_subs = self.subs.clone();
12279 let prior_outbox = self.outbox.clone();
12280 let prior_rejections = self.rejections.clone();
12281 let prior_upgrading = self.upgrading;
12282 let prior_stopped = self.stopped;
12283 let prior_schema_floor = self.schema_floor.clone();
12284 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12285 let prior_sync_needed = self.sync_needed;
12286 let prior_active_round = self.active_round;
12287 let prior_changes = self.change_queue.len();
12288 let prior_intents = self.sync_intent_queue.len();
12289 let prior_last_change = self.last_change.clone();
12290 let guarded = (|| -> Result<(), String> {
12291 let marker = read_local_schema_version(&self.conn, self.schema.version)?;
12296 self.create_bookkeeping_tables()?;
12297 match marker {
12298 Some(version) if version < self.schema.version => self.run_schema_reset()?,
12299 None => {
12300 self.create_synced_tables()?;
12301 self.persist_schema_version()?;
12302 }
12303 _ => {}
12304 }
12305 self.outbox = self.load_outbox()?;
12310 self.drop_unencodable_outbox(false, None)?;
12315 if marker == Some(self.schema.version)
12316 && (!self.outbox.is_empty()
12317 || !self.failed_commits.is_empty()
12318 || self.has_acknowledged_rows())
12319 {
12320 self.overlay_dirty.set(true);
12321 self.rebuild_overlay()?;
12322 }
12323 self.prune_unknown_subscriptions(false)?;
12324 self.save_subscription_scope_schema();
12325 self.enqueue_startup_sync_if_needed()?;
12329 Ok(())
12330 })();
12331 let outcome = match guarded {
12332 Ok(()) => self
12333 .conn
12334 .execute_batch("COMMIT")
12335 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
12336 Err(error) => Err(error),
12337 };
12338 if let Err(error) = outcome {
12339 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12340 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
12342 }
12343 self.schema = prior_schema;
12344 self.subs = prior_subs;
12345 self.outbox = prior_outbox;
12346 self.rejections = prior_rejections;
12347 self.upgrading = prior_upgrading;
12348 self.stopped = prior_stopped;
12349 self.schema_floor = prior_schema_floor;
12350 self.overlay_dirty.restore(prior_overlay_dirty);
12351 self.sync_needed = prior_sync_needed;
12352 self.active_round = prior_active_round;
12353 self.change_queue.truncate(prior_changes);
12354 self.sync_intent_queue.truncate(prior_intents);
12355 self.last_change = prior_last_change;
12356 self.insert_sql.borrow_mut().clear();
12358 return Err(error);
12359 }
12360 Ok(())
12361 }
12362
12363 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
12364 let startup_work = !self.stopped
12365 && (!self.outbox.is_empty()
12366 || self.has_acknowledged_rows()
12367 || !self.load_pending_evictions()?.is_empty()
12368 || self.subs.iter().any(|sub| sub.state == SubState::Active));
12369 if startup_work {
12370 self.sync_needed = true;
12371 self.sync_intent_queue.push_back(SyncIntent::Interactive);
12372 }
12373 Ok(())
12374 }
12375
12376 fn clear_satisfied_persisted_schema_floor(&mut self) {
12383 let satisfied = self
12384 .schema_floor
12385 .as_ref()
12386 .and_then(|floor| floor.required_schema_version)
12387 .is_some_and(|required| self.schema.version >= required);
12388 if !satisfied {
12389 return;
12390 }
12391 self.schema_floor = None;
12392 self.stopped = false;
12393 self.delete_meta(SCHEMA_FLOOR_KEY);
12394 }
12395
12396 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
12401 let Some(replica_path) = self.previous_version_replica_path() else {
12402 return Ok(());
12403 };
12404 let max_age_ms = self
12405 .previous_version
12406 .filter(|config| config.enabled)
12407 .map(|config| config.max_age_ms);
12408 reconcile_previous_version_at_boot(
12409 &self.conn,
12410 &replica_path,
12411 self.schema.version,
12412 max_age_ms,
12413 self.clock_now_ms(),
12414 )
12415 }
12416
12417 fn previous_version_replica_path(&self) -> Option<String> {
12421 let path = self.conn.path()?;
12422 if path.is_empty() || path.starts_with("file:") {
12423 return None;
12424 }
12425 Some(path.to_owned())
12426 }
12427
12428 fn run_schema_reset(&mut self) -> Result<(), String> {
12434 self.begin_observation("syncular_schema_reset")?;
12435 let prior_subs = self.subs.clone();
12436 let prior_outbox = self.outbox.clone();
12437 let prior_rejections = self.rejections.clone();
12438 let prior_upgrading = self.upgrading;
12439 let prior_stopped = self.stopped;
12440 let prior_schema_floor = self.schema_floor.clone();
12441 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12442 let prior_sync_needed = self.sync_needed;
12443 let prior_active_round = self.active_round;
12444 let mut batch = ChangeAccumulator::default();
12445 let result = self
12446 .run_schema_reset_observed(&mut batch, true)
12447 .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
12448 if let Err(error) = result {
12449 self.rollback_observation("syncular_schema_reset");
12450 self.subs = prior_subs;
12451 self.outbox = prior_outbox;
12452 self.rejections = prior_rejections;
12453 self.upgrading = prior_upgrading;
12454 self.stopped = prior_stopped;
12455 self.schema_floor = prior_schema_floor;
12456 self.overlay_dirty.restore(prior_overlay_dirty);
12457 self.sync_needed = prior_sync_needed;
12458 self.active_round = prior_active_round;
12459 return Err(error);
12460 }
12461 Ok(())
12462 }
12463
12464 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
12465 if self.get_meta(LOG_EPOCH_KEY).is_none() {
12466 self.begin_observation("syncular_epoch_acquisition")?;
12467 let previous_sync_needed = self.sync_needed;
12468 if let Err(error) = self.conn.execute(
12469 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12470 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
12471 ) {
12472 self.rollback_observation("syncular_epoch_acquisition");
12473 return Err(Self::sqlite_failure(&self.storage_failure, error));
12474 }
12475 self.sync_needed = true;
12476 let batch = ChangeAccumulator {
12477 status: true,
12478 ..ChangeAccumulator::default()
12479 };
12480 if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
12481 self.rollback_observation("syncular_epoch_acquisition");
12482 self.sync_needed = previous_sync_needed;
12483 return Err(error);
12484 }
12485 self.sync_intent_queue.push_back(SyncIntent::Interactive);
12486 return Ok(Vec::new());
12487 }
12488 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
12489 self.begin_observation("syncular_log_epoch_reset")?;
12490 let prior_subs = self.subs.clone();
12491 let prior_outbox = self.outbox.clone();
12492 let prior_rejections = self.rejections.clone();
12493 let prior_upgrading = self.upgrading;
12494 let prior_stopped = self.stopped;
12495 let prior_schema_floor = self.schema_floor.clone();
12496 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12497 let prior_sync_needed = self.sync_needed;
12498 let prior_active_round = self.active_round;
12499 let mut batch = ChangeAccumulator::default();
12500 let result = self
12501 .run_schema_reset_observed(&mut batch, false)
12502 .and_then(|()| {
12503 self.conn
12504 .execute(
12505 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12506 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
12507 )
12508 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12509 self.sync_needed = true;
12510 batch.status = true;
12511 self.finish_observation("syncular_log_epoch_reset", batch)
12512 });
12513 if let Err(error) = result {
12514 self.rollback_observation("syncular_log_epoch_reset");
12515 self.subs = prior_subs;
12516 self.outbox = prior_outbox;
12517 self.rejections = prior_rejections;
12518 self.upgrading = prior_upgrading;
12519 self.stopped = prior_stopped;
12520 self.schema_floor = prior_schema_floor;
12521 self.overlay_dirty.restore(prior_overlay_dirty);
12522 self.sync_needed = prior_sync_needed;
12523 self.active_round = prior_active_round;
12524 return Err(error);
12525 }
12526 self.sync_intent_queue.push_back(SyncIntent::Interactive);
12527 Ok(resets)
12528 }
12529
12530 fn run_schema_reset_observed(
12531 &mut self,
12532 batch: &mut ChangeAccumulator,
12533 capture: bool,
12534 ) -> Result<(), String> {
12535 read_local_schema_version(&self.conn, self.schema.version)?;
12541 self.conn
12542 .execute_batch(
12543 "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
12544 )
12545 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12546 self.cancel_sync_round();
12547 self.upgrading = true;
12548 batch.status = true;
12549 if self.previous_version.is_some_and(|config| config.enabled) {
12557 let previous_version = self
12558 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
12559 .and_then(|value| value.parse::<i32>().ok())
12560 .unwrap_or(self.schema.version);
12561 let pending = self
12562 .outbox
12563 .iter()
12564 .map(|commit| PendingCommitAudit {
12565 commit_id: &commit.client_commit_id,
12566 operations: commit_audit_operations(commit),
12567 })
12568 .collect::<Vec<_>>();
12569 crate::previous_version::write_previous_version_audit(
12570 &self.conn,
12571 &crate::previous_version::build_previous_version_audit(
12572 &self.schema,
12573 &pending,
12574 previous_version,
12575 self.schema.version,
12576 self.clock_now_ms(),
12577 ),
12578 );
12579 }
12580 if let Some(replica_path) = self.previous_version_replica_path() {
12585 sweep_previous_version_container(&self.conn, &replica_path)?;
12586 if capture {
12587 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
12588 let previous_version = self
12589 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
12590 .and_then(|value| value.parse::<i32>().ok())
12591 .unwrap_or(self.schema.version);
12592 capture_previous_version_from_replica(
12593 &self.conn,
12594 &replica_path,
12595 previous_version,
12596 self.schema.version,
12597 &config,
12598 self.clock_now_ms(),
12599 )?;
12600 }
12601 }
12602 }
12603 for table in &self.schema.tables {
12604 batch.table(&table.name);
12605 }
12606 for (base_key, unit, table) in self.load_registered_window_units() {
12607 batch.window(&base_key, &table, &unit);
12608 }
12609 if capture {
12610 self.prune_unknown_subscriptions(true)?;
12611 }
12612 self.insert_sql.borrow_mut().clear();
12614 self.overlay_dirty.set(true);
12615 let virtual_tables: Vec<String> = {
12623 let mut stmt = self
12624 .conn
12625 .prepare(
12626 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
12627 )
12628 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12629 let rows = stmt
12630 .query_map([], |row| row.get::<_, String>(0))
12631 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12632 rows.filter_map(Result::ok)
12633 .filter(|name| is_synced_table_name(name))
12634 .collect()
12635 };
12636 for name in virtual_tables {
12637 self.conn
12638 .execute_batch(&format!(
12639 "DROP TABLE IF EXISTS {}",
12640 quote_ident(&format!("_syncular_fts_{name}"))
12641 ))
12642 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12643 self.conn
12644 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
12645 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12646 }
12647 let existing: Vec<String> = {
12648 let mut stmt = self
12649 .conn
12650 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
12651 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12652 let rows = stmt
12653 .query_map([], |row| row.get::<_, String>(0))
12654 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12655 rows.filter_map(Result::ok)
12656 .filter(|name| is_synced_table_name(name))
12657 .collect()
12658 };
12659 for name in &existing {
12660 if let Some(table) = name.strip_prefix("_syncular_base_") {
12661 batch.table(table);
12662 } else if !name.starts_with("_syncular_") {
12663 batch.table(name);
12664 }
12665 }
12666 for name in existing {
12667 self.conn
12668 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
12669 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12670 }
12671 self.create_synced_tables()?;
12673 for sub in &mut self.subs {
12675 sub.cursor = -1;
12676 sub.bootstrap_state = None;
12677 sub.effective = None;
12678 sub.state = SubState::Active;
12679 sub.reason_code = None;
12680 sub.synced_once = false;
12681 }
12682 let subs = self.subs.clone();
12683 for sub in &subs {
12684 self.persist_sub(sub)?;
12685 }
12686 self.stopped = false;
12688 self.schema_floor = None;
12689 self.delete_meta(SCHEMA_FLOOR_KEY);
12690 self.persist_schema_version()?;
12692 self.save_subscription_scope_schema();
12693 self.drop_unencodable_outbox(false, Some(batch))?;
12704 self.rebuild_overlay()?;
12706 Ok(())
12707 }
12708
12709 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
12716 let incompatible = self
12717 .outbox
12718 .iter()
12719 .filter(|commit| {
12720 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
12721 })
12722 .cloned()
12723 .collect::<Vec<_>>();
12724 if incompatible.is_empty() {
12725 return Ok(false);
12726 }
12727 let prior_outbox = self.outbox.clone();
12733 let prior_rejections = self.rejections.clone();
12734 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12735 let owns_transaction = self.conn.is_autocommit();
12736 self.begin_observation("syncular_outbox_drop")?;
12737 let mut batch = ChangeAccumulator::default();
12738 let dropped = (|| -> Result<(), String> {
12739 let mut rejections = Vec::new();
12740 for commit in &incompatible {
12741 let results = commit
12742 .ops
12743 .iter()
12744 .enumerate()
12745 .map(|(op_index, operation)| {
12746 let rejection = RejectionRecord {
12747 client_commit_id: commit.client_commit_id.clone(),
12748 op_index: op_index as i32,
12749 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
12750 message: "the persisted commit cannot encode under the current schema"
12751 .to_owned(),
12752 retryable: false,
12753 details: None,
12754 operation: Some(CommitOperation::from(operation)),
12755 };
12756 rejections.push(rejection.clone());
12757 CommitOperationOutcome::Error { rejection }
12758 })
12759 .collect::<Vec<_>>();
12760 self.persist_commit_outcome(
12761 &commit.client_commit_id,
12762 CommitOutcomeStatus::Rejected,
12763 &results,
12764 Some(&commit.ops),
12765 )?;
12766 self.delete_outbox_persisted(&commit.client_commit_id)?;
12767 for operation in &commit.ops {
12770 if self.schema.table(&operation.table).is_some() {
12771 batch.table(&operation.table);
12772 }
12773 }
12774 }
12775 self.prune_commit_outcomes()?;
12776 let incompatible_ids = incompatible
12777 .iter()
12778 .map(|commit| commit.client_commit_id.as_str())
12779 .collect::<BTreeSet<_>>();
12780 self.outbox
12781 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
12782 self.rejections.extend(rejections);
12783 self.overlay_dirty.set(true);
12786 self.rebuild_overlay()?;
12787 batch.status = true;
12788 batch.rejections = true;
12789 batch.outcomes = true;
12790 Ok(())
12791 })();
12792 let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
12793 if let Err(error) = result {
12794 if owns_transaction {
12798 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12799 let mut failure = self.storage_failure.borrow_mut();
12800 let failure =
12801 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
12802 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
12803 }
12804 } else {
12805 self.rollback_observation("syncular_outbox_drop");
12806 }
12807 self.outbox = prior_outbox;
12808 self.rejections = prior_rejections;
12809 self.overlay_dirty.restore(prior_overlay_dirty);
12810 return Err(error);
12811 }
12812 Ok(true)
12813 }
12814
12815 fn create_synced_tables(&self) -> Result<(), String> {
12818 for table in &self.schema.tables {
12819 for (full, index_prefix) in [
12823 (base_table(&table.name), "_syncular_base_"),
12824 (visible_table(&table.name), ""),
12825 ] {
12826 let mut cols: Vec<String> =
12827 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
12828 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
12829 let sql = format!(
12830 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
12831 cols.join(", "),
12832 quote_ident(&table.primary_key)
12833 );
12834 self.conn
12835 .execute(&sql, [])
12836 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12837 for index in &table.indexes {
12841 let unique = if index.unique { "UNIQUE " } else { "" };
12842 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
12843 let cols_sql = index
12844 .columns
12845 .iter()
12846 .map(|c| quote_ident(c))
12847 .collect::<Vec<_>>()
12848 .join(", ");
12849 let index_sql = format!(
12850 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
12851 );
12852 self.conn
12853 .execute(&index_sql, [])
12854 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12855 }
12856 }
12857 }
12858 for table in &self.schema.tables {
12861 for index in &table.fts_indexes {
12862 self.create_fts_projection(table, index)?;
12863 }
12864 }
12865 Ok(())
12866 }
12867
12868 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
12869 let count: i64 = self
12870 .conn
12871 .query_row(
12872 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
12873 rusqlite::params![index.name],
12874 |row| row.get(0),
12875 )
12876 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12877 Ok(count > 0)
12878 }
12879
12880 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
12881 for suffix in ["ai", "ad", "au"] {
12882 self.conn
12883 .execute(
12884 &format!(
12885 "DROP TRIGGER IF EXISTS {}",
12886 quote_ident(&format!("{}_{suffix}", index.name))
12887 ),
12888 [],
12889 )
12890 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12891 }
12892 Ok(())
12893 }
12894
12895 fn create_fts_triggers(
12896 &self,
12897 table: &TableSchema,
12898 index: &FtsIndexSchema,
12899 ) -> Result<(), String> {
12900 let fts = quote_ident(&index.name);
12901 let source = visible_table(&table.name);
12902 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
12903 let pk = quote_ident(&table.primary_key);
12904 let projection_columns = std::iter::once(source_id.clone())
12905 .chain(index.columns.iter().map(|column| quote_ident(column)))
12906 .collect::<Vec<_>>()
12907 .join(", ");
12908 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
12909 .chain(
12910 index
12911 .columns
12912 .iter()
12913 .map(|column| format!("new.{}", quote_ident(column))),
12914 )
12915 .collect::<Vec<_>>()
12916 .join(", ");
12917 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12918 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)");
12919 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)");
12920 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})");
12921 let bi = quote_ident(&format!("{}_bi", index.name));
12922 let ai = quote_ident(&format!("{}_ai", index.name));
12923 let ad = quote_ident(&format!("{}_ad", index.name));
12924 let au = quote_ident(&format!("{}_au", index.name));
12925 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
12926 let sql = format!(
12927 "DROP TRIGGER IF EXISTS {bi};
12928 DROP TRIGGER IF EXISTS {ai};
12929 DROP TRIGGER IF EXISTS {ad};
12930 DROP TRIGGER IF EXISTS {au};
12931 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
12932 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
12933 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
12934 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
12935 );
12936 self.conn
12937 .execute_batch(&sql)
12938 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12939 }
12940
12941 fn rebuild_fts_projection(
12942 &self,
12943 table: &TableSchema,
12944 index: &FtsIndexSchema,
12945 ) -> Result<(), String> {
12946 let fts = quote_ident(&index.name);
12947 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
12948 let pk = quote_ident(&table.primary_key);
12949 let indexed_columns = index
12950 .columns
12951 .iter()
12952 .map(|column| quote_ident(column))
12953 .collect::<Vec<_>>();
12954 let projection_columns = std::iter::once(source_id.clone())
12955 .chain(indexed_columns.iter().cloned())
12956 .collect::<Vec<_>>()
12957 .join(", ");
12958 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12959 let sql = format!(
12960 "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};",
12961 columns = indexed_columns.join(", "),
12962 source = visible_table(&table.name),
12963 );
12964 self.conn
12965 .execute_batch(&sql)
12966 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12967 }
12968
12969 fn create_fts_projection(
12970 &self,
12971 table: &TableSchema,
12972 index: &FtsIndexSchema,
12973 ) -> Result<(), String> {
12974 self.begin_observation("syncular_fts_schema")?;
12975 let result = (|| {
12976 let existed = self.fts_projection_exists(index)?;
12977 let tokenizer = index.tokenize.replace('\'', "''");
12978 let columns = index
12979 .columns
12980 .iter()
12981 .map(|column| quote_ident(column))
12982 .collect::<Vec<_>>()
12983 .join(", ");
12984 let sql = format!(
12985 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
12986 fts = quote_ident(&index.name),
12987 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
12988 );
12989 self.conn.execute(&sql, []).map_err(|error| {
12990 format!(
12991 "cannot create local FTS5 projection {:?}: {error}",
12992 index.name
12993 )
12994 })?;
12995 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12996 let mapped: bool = self
12997 .conn
12998 .query_row(
12999 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
13000 [format!("_syncular_fts_{}", index.name)],
13001 |row| row.get(0),
13002 )
13003 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13004 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))?;
13005 if !mapped && existed {
13006 self.conn
13007 .execute_batch(&format!(
13008 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
13009 quote_ident(FTS_SOURCE_ID_COLUMN),
13010 quote_ident(&index.name)
13011 ))
13012 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13013 }
13014 self.create_fts_triggers(table, index)?;
13015 if !existed {
13016 self.rebuild_fts_projection(table, index)?;
13017 }
13018 Ok(())
13019 })();
13020 if let Err(error) = result {
13021 self.rollback_observation("syncular_fts_schema");
13022 return Err(error);
13023 }
13024 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
13025 self.rollback_observation("syncular_fts_schema");
13026 return Err(error.to_string());
13027 }
13028 Ok(())
13029 }
13030
13031 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
13034 let state = serde_json::json!({
13035 "requested": scope_map_to_json(&sub.requested),
13036 "params": sub.params,
13037 "cursor": sub.cursor,
13038 "bootstrapState": sub.bootstrap_state,
13039 "status": sub.state.name(),
13040 "reasonCode": sub.reason_code,
13041 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
13042 "syncedOnce": sub.synced_once,
13043 });
13044 self.conn.execute(
13045 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
13046 rusqlite::params![sub.id, sub.table, state.to_string()],
13047 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13048 }
13049
13050 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
13051 let ops: Vec<Value> = commit
13052 .ops
13053 .iter()
13054 .map(|op| {
13055 serde_json::json!({
13056 "op": if op.upsert { "upsert" } else { "delete" },
13057 "table": op.table,
13058 "rowId": op.row_id,
13059 "baseVersion": op.base_version,
13060 "values": op.values.clone().map(Value::Object),
13061 })
13062 })
13063 .collect();
13064 self.conn
13065 .execute(
13066 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
13067 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
13068 )
13069 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13070 for operation in &commit.ops {
13071 let Some(values) = &operation.values else {
13072 continue;
13073 };
13074 let Some(table) = self.schema.table(&operation.table) else {
13075 continue;
13076 };
13077 for column in table
13078 .columns
13079 .iter()
13080 .filter(|column| column.ty == ColumnType::BlobRef)
13081 {
13082 let Some(Value::String(raw)) = values.get(&column.name) else {
13083 continue;
13084 };
13085 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
13086 continue;
13087 };
13088 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
13089 continue;
13090 };
13091 self.conn
13092 .execute(
13093 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
13094 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
13095 rusqlite::params![commit.client_commit_id, blob_id],
13096 )
13097 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13098 }
13099 }
13100 Ok(())
13101 }
13102
13103 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
13104 if let Some(commit) = self
13105 .outbox
13106 .iter()
13107 .find(|commit| commit.client_commit_id == client_commit_id)
13108 {
13109 for operation in &commit.ops {
13110 self.overlay_dirty.table(&operation.table);
13111 }
13112 }
13113
13114 self.conn
13115 .execute(
13116 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
13117 rusqlite::params![client_commit_id],
13118 )
13119 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13120 self.conn
13121 .execute(
13122 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
13123 rusqlite::params![client_commit_id],
13124 )
13125 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13126 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)", [])
13127 .map(|_| ())
13128 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13129 }
13130
13131 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
13132 match status {
13133 CommitOutcomeStatus::Applied => "applied",
13134 CommitOutcomeStatus::Cached => "cached",
13135 CommitOutcomeStatus::Conflict => "conflict",
13136 CommitOutcomeStatus::Rejected => "rejected",
13137 }
13138 }
13139
13140 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
13141 match resolution {
13142 CommitOutcomeResolution::Active => "active",
13143 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
13144 CommitOutcomeResolution::Superseded => "superseded",
13145 CommitOutcomeResolution::Dismissed => "dismissed",
13146 }
13147 }
13148
13149 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
13150 match value {
13151 "applied" => Ok(CommitOutcomeStatus::Applied),
13152 "cached" => Ok(CommitOutcomeStatus::Cached),
13153 "conflict" => Ok(CommitOutcomeStatus::Conflict),
13154 "rejected" => Ok(CommitOutcomeStatus::Rejected),
13155 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
13156 }
13157 }
13158
13159 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
13160 match value {
13161 "active" => Ok(CommitOutcomeResolution::Active),
13162 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
13163 "superseded" => Ok(CommitOutcomeResolution::Superseded),
13164 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
13165 _ => Err(format!(
13166 "invalid persisted commit outcome resolution {value:?}"
13167 )),
13168 }
13169 }
13170
13171 fn persist_commit_outcome(
13172 &self,
13173 client_commit_id: &str,
13174 status: CommitOutcomeStatus,
13175 results: &[CommitOperationOutcome],
13176 operations: Option<&[OutboxOp]>,
13177 ) -> Result<(), String> {
13178 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
13179 let operations_json = operations
13180 .map(|items| {
13181 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
13182 })
13183 .transpose()
13184 .map_err(|error| error.to_string())?;
13185 self.conn
13186 .execute(
13187 "INSERT INTO _syncular_commit_outcomes (
13188 client_commit_id, status, recorded_at_ms, results_json,
13189 operations_json, resolution
13190 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
13191 rusqlite::params![
13192 client_commit_id,
13193 Self::outcome_status_name(status),
13194 self.clock_now_ms(),
13195 results_json,
13196 operations_json
13197 ],
13198 )
13199 .map(|_| ())
13200 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13201 }
13202
13203 fn stored_cell_is_representable(cell: &Value) -> bool {
13206 cell.is_null()
13207 || cell.is_string()
13208 || cell.is_boolean()
13209 || cell.is_number()
13210 || is_canonical_bytes_envelope(cell)
13211 }
13212
13213 fn stored_row_is_representable(value: &Value) -> bool {
13214 value
13215 .as_object()
13216 .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
13217 }
13218
13219 fn stored_operation_is_representable(value: &Value) -> bool {
13220 value.get("table").is_some_and(Value::is_string)
13221 && value.get("rowId").is_some_and(Value::is_string)
13222 && value.get("baseVersion").is_none_or(|v| {
13223 v.as_i64()
13224 .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
13225 })
13226 && match value.get("op").and_then(Value::as_str) {
13227 Some("upsert") => value
13228 .get("values")
13229 .is_some_and(Self::stored_row_is_representable),
13230 Some("delete") => value.get("values").is_none(),
13231 _ => false,
13232 }
13233 }
13234
13235 fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
13239 serde_json::to_value(CommitOperation::from(operation))
13240 .is_ok_and(|value| Self::stored_operation_is_representable(&value))
13241 }
13242
13243 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
13244 let StoredCommitOutcomeRow {
13245 sequence,
13246 client_commit_id,
13247 status,
13248 recorded_at_ms,
13249 results_json,
13250 operations_json,
13251 resolution,
13252 resolved_at_ms,
13253 replacement_client_commit_id,
13254 } = row;
13255 if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
13256 || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
13257 || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
13258 {
13259 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
13260 }
13261 let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
13262 let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
13263 for result in &stored {
13264 let nested = match result.get("status").and_then(Value::as_str) {
13265 Some("conflict") => result.get("conflict"),
13266 Some("error") => result.get("rejection"),
13267 _ => None,
13268 };
13269 if nested
13270 .and_then(|record| record.get("operation"))
13271 .is_some_and(|op| !Self::stored_operation_is_representable(op))
13272 || (result.get("status").and_then(Value::as_str) == Some("conflict")
13273 && nested
13274 .and_then(|record| record.get("serverRow"))
13275 .is_some_and(|row| !Self::stored_row_is_representable(row)))
13276 {
13277 return Err(invalid());
13278 }
13279 }
13280 let results: Vec<CommitOperationOutcome> =
13281 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
13282 let has_conflict = results
13283 .iter()
13284 .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
13285 let has_error = results
13286 .iter()
13287 .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
13288 let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
13289 let redacted = results.is_empty() && operations_json.is_none();
13290 if match status {
13291 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
13292 CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
13293 CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
13294 } {
13295 return Err(invalid());
13296 }
13297 let operations: Option<Vec<CommitOperation>> = operations_json
13298 .map(|raw| {
13299 let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
13300 if !stored.iter().all(Self::stored_operation_is_representable) {
13301 return Err(invalid());
13302 }
13303 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
13304 })
13305 .transpose()?;
13306 for result in &results {
13307 match result {
13308 CommitOperationOutcome::Conflict { conflict }
13309 if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
13310 {
13311 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
13312 }
13313 CommitOperationOutcome::Error { rejection } => {
13314 if let Some(details) = &rejection.details {
13315 let encoded = serde_json::to_string(details).map_err(|_| {
13316 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
13317 })?;
13318 RejectionDetails::parse(&encoded).map_err(|_| {
13319 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
13320 })?;
13321 }
13322 }
13323 _ => {}
13324 }
13325 }
13326 Ok(CommitOutcome {
13327 sequence,
13328 client_commit_id,
13329 status,
13330 recorded_at_ms,
13331 results,
13332 operations,
13333 retained_rows: None,
13334 resolution: Self::parse_outcome_resolution(&resolution)?,
13335 resolved_at_ms,
13336 replacement_client_commit_id,
13337 })
13338 }
13339
13340 fn unique_conflicts(
13342 &self,
13343 table: &TableSchema,
13344 values: &Map<String, Value>,
13345 base: bool,
13346 ) -> Result<Vec<RetainedUniqueConflict>, String> {
13347 let source = if base {
13348 base_table(&table.name)
13349 } else {
13350 visible_table(&table.name)
13351 };
13352 let mut conflicts = Vec::new();
13353 for index in table.indexes.iter().filter(|index| index.unique) {
13354 let mut params = vec![json_param_to_sql(
13355 values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
13356 )?];
13357 for name in &index.columns {
13358 let column = table
13359 .columns
13360 .iter()
13361 .find(|column| column.name == *name)
13362 .ok_or("sync.local_corrupt")?;
13363 let cell = json_to_column_value(column, values.get(name))?;
13364 params.push(owned_sql_value(RowParam::Cell(&cell))?);
13365 }
13366 let sql = format!(
13367 "SELECT * FROM {source} WHERE {} != ? AND {}",
13368 quote_ident(&table.primary_key),
13369 index
13370 .columns
13371 .iter()
13372 .map(|column| format!("{} = ?", quote_ident(column)))
13373 .collect::<Vec<_>>()
13374 .join(" AND ")
13375 );
13376 let mut statement = self
13377 .conn
13378 .prepare_cached(&sql)
13379 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13380 let records = statement
13381 .query_map(rusqlite::params_from_iter(params), |record| {
13382 let values = table
13383 .columns
13384 .iter()
13385 .enumerate()
13386 .map(|(index, column)| {
13387 let value = sql_ref_to_json(column, record.get_ref(index)?);
13388 json_to_column_value(column, Some(&value)).map_err(|message| {
13389 rusqlite::Error::ToSqlConversionFailure(message.into())
13390 })
13391 })
13392 .collect::<Result<Row, _>>()?;
13393 Ok((values, record.get::<_, i64>(table.columns.len())?))
13394 })
13395 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13396 for record in records {
13397 let (row, version) =
13398 record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13399 let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
13400 conflicts.push(RetainedUniqueConflict {
13401 index: index.name.clone(),
13402 columns: index.columns.clone(),
13403 row_id,
13404 server_row: table
13405 .columns
13406 .iter()
13407 .zip(&row)
13408 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
13409 .collect(),
13410 server_version: version,
13411 });
13412 }
13413 }
13414 Ok(conflicts)
13415 }
13416
13417 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
13418 if let Some((commit, initial)) = self
13419 .failed_commits
13420 .iter()
13421 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
13422 {
13423 let mut rows = Vec::new();
13424 for operation in &commit.ops {
13425 let table = self
13426 .schema
13427 .table(&operation.table)
13428 .ok_or_else(|| "sync.unknown_table".to_owned())?;
13429 let key =
13430 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
13431 .map_err(|_| "sync.local_corrupt".to_owned())?;
13432 let base = self.stored_row(table, &operation.row_id, true)?;
13433 rows.push(RetainedCommitRow {
13434 table: operation.table.clone(),
13435 row_id: operation.row_id.clone(),
13436 unique_conflicts: if operation.upsert {
13437 self.unique_conflicts(
13438 table,
13439 initial
13440 .get(&key)
13441 .and_then(Value::as_object)
13442 .or(operation.values.as_ref())
13443 .ok_or("sync.local_corrupt")?,
13444 true,
13445 )?
13446 } else {
13447 Vec::new()
13448 },
13449 local_row: if operation.upsert {
13450 initial
13451 .get(&key)
13452 .and_then(Value::as_object)
13453 .cloned()
13454 .or_else(|| operation.values.clone())
13455 } else {
13456 None
13457 },
13458 server_row: base.as_ref().map(|(values, _)| {
13459 table
13460 .columns
13461 .iter()
13462 .zip(values)
13463 .map(|(column, value)| {
13464 (column.name.clone(), column_value_to_json(value))
13465 })
13466 .collect()
13467 }),
13468 server_version: base.map(|(_, version)| version),
13469 });
13470 }
13471 outcome.retained_rows = Some(rows);
13472 }
13473 Ok(outcome)
13474 }
13475
13476 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
13477 let row = self
13478 .conn
13479 .query_row(
13480 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
13481 resolution, resolved_at_ms, replacement_client_commit_id
13482 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
13483 rusqlite::params![client_commit_id],
13484 |row| {
13485 Ok(StoredCommitOutcomeRow {
13486 sequence: row.get(0)?,
13487 client_commit_id: row.get(1)?,
13488 status: row.get(2)?,
13489 recorded_at_ms: row.get(3)?,
13490 results_json: row.get(4)?,
13491 operations_json: row.get(5)?,
13492 resolution: row.get(6)?,
13493 resolved_at_ms: row.get(7)?,
13494 replacement_client_commit_id: row.get(8)?,
13495 })
13496 },
13497 )
13498 .optional()
13499 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13500 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
13501 .transpose()
13502 }
13503
13504 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
13505 if query.limit == Some(0) {
13506 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
13507 }
13508 let mut sql = String::from(
13509 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
13510 resolution, resolved_at_ms, replacement_client_commit_id
13511 FROM _syncular_commit_outcomes",
13512 );
13513 if query.active_only {
13514 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
13515 }
13516 sql.push_str(" ORDER BY seq DESC");
13517 if let Some(limit) = query.limit {
13518 sql.push_str(&format!(" LIMIT {limit}"));
13519 }
13520 let mut stmt = self
13521 .conn
13522 .prepare(&sql)
13523 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13524 let rows = stmt
13525 .query_map([], |row| {
13526 Ok(StoredCommitOutcomeRow {
13527 sequence: row.get(0)?,
13528 client_commit_id: row.get(1)?,
13529 status: row.get(2)?,
13530 recorded_at_ms: row.get(3)?,
13531 results_json: row.get(4)?,
13532 operations_json: row.get(5)?,
13533 resolution: row.get(6)?,
13534 resolved_at_ms: row.get(7)?,
13535 replacement_client_commit_id: row.get(8)?,
13536 })
13537 })
13538 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13539 let mut outcomes = Vec::new();
13540 for row in rows {
13541 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
13542 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
13543 )?)?);
13544 }
13545 Ok(outcomes)
13546 }
13547
13548 fn prune_commit_outcomes(&self) -> Result<(), String> {
13549 #[cfg(test)]
13550 self.outcome_prune_count
13551 .set(self.outcome_prune_count.get() + 1);
13552 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
13553 let count = self
13554 .conn
13555 .query_row(
13556 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
13557 [],
13558 |row| row.get::<_, i64>(0),
13559 )
13560 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13561 as usize;
13562 let excess = count.saturating_sub(max_entries);
13563 if excess == 0 {
13564 return Ok(());
13565 }
13566 let mut stmt = self
13567 .conn
13568 .prepare(
13569 "SELECT seq FROM _syncular_commit_outcomes
13570 WHERE status IN ('applied', 'cached') OR resolution != 'active'
13571 ORDER BY seq ASC LIMIT ?1",
13572 )
13573 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13574 let rows = stmt
13575 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
13576 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13577 let sequences = rows
13578 .collect::<Result<Vec<_>, _>>()
13579 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13580 drop(stmt);
13581 for sequence in sequences {
13582 self.conn
13583 .execute(
13584 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
13585 rusqlite::params![sequence],
13586 )
13587 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13588 }
13589 Ok(())
13590 }
13591
13592 pub fn subscribe(
13595 &mut self,
13596 id: String,
13597 table: String,
13598 scopes: Vec<(String, Vec<String>)>,
13599 params: Option<String>,
13600 ) -> Result<(), String> {
13601 if self.schema.table(&table).is_none() {
13602 return Err(format!("unknown table {table:?}"));
13603 }
13604 let registered_at = self
13605 .subs
13606 .iter()
13607 .position(|subscription| subscription.id == id);
13608 if let Some(index) = registered_at {
13609 let existing = &self.subs[index];
13610 let same_intent = existing.table == table
13611 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
13612 && existing.params == params;
13613 if !same_intent {
13614 return Err(
13615 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
13616 .to_owned(),
13617 );
13618 }
13619 if existing.state != SubState::Revoked {
13624 return Ok(());
13625 }
13626 }
13627 let sub = Subscription {
13628 id: id.clone(),
13629 table,
13630 requested: scopes,
13631 params,
13632 cursor: -1,
13633 bootstrap_state: None,
13634 state: SubState::Active,
13635 reason_code: None,
13636 effective: None,
13637 synced_once: false,
13638 };
13639 self.persist_sub(&sub)?;
13640 match registered_at {
13641 Some(index) => self.subs[index] = sub,
13642 None => self.subs.push(sub),
13643 }
13644 if registered_at.is_some() {
13648 self.set_sync_needed(true, true);
13649 }
13650 Ok(())
13651 }
13652
13653 pub fn subscribe_many(&mut self, requests: Vec<SubscriptionRequest>) -> Result<(), String> {
13659 let prior_subs = self.subs.clone();
13660 self.begin_observation("syncular_subscribe")?;
13661 let registered = (|| {
13662 for request in requests {
13663 self.subscribe(request.id, request.table, request.scopes, request.params)?;
13664 }
13665 self.conn
13666 .execute_batch("RELEASE syncular_subscribe")
13667 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13668 })();
13669 if let Err(error) = registered {
13670 self.rollback_observation("syncular_subscribe");
13671 self.subs = prior_subs;
13672 return Err(error);
13673 }
13674 Ok(())
13675 }
13676
13677 pub fn unsubscribe(&mut self, id: &str) {
13678 if self.subs.iter().any(|s| s.id == id) {
13679 self.cancel_sync_round();
13680 }
13681 self.subs.retain(|s| s.id != id);
13682 let _ = self.conn.execute(
13683 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
13684 rusqlite::params![id],
13685 );
13686 }
13687
13688 pub fn set_window(
13696 &mut self,
13697 base: &WindowBase,
13698 units: &[String],
13699 ) -> Result<CommandEffects, String> {
13700 let table = self
13701 .schema
13702 .table(&base.table)
13703 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
13704 if table.scope_column(&base.variable).is_none() {
13705 return Err(format!(
13706 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
13707 base.table, base.variable
13708 ));
13709 }
13710 let base_key = window_base_key(base);
13711 let wanted: std::collections::HashSet<&String> = units.iter().collect();
13712 let live = self.load_window_units(&base_key);
13713 self.begin_observation("syncular_window")?;
13714 let mut batch = ChangeAccumulator::default();
13715 let mut changed = false;
13716
13717 let prior_subs = self.subs.clone();
13718 let widened = (|| {
13719 for unit in units {
13721 if live.iter().any(|(u, _)| u == unit) {
13722 continue;
13723 }
13724 let sub_id = derive_sub_id(base, unit);
13725 self.delete_pending_evict(&sub_id)?;
13726 self.insert_window_unit(&base_key, unit, &sub_id)?;
13727 self.subscribe(
13728 sub_id,
13729 base.table.clone(),
13730 unit_scopes(base, unit),
13731 base.params.clone(),
13732 )?;
13733 batch.window(&base_key, &base.table, unit);
13734 changed = true;
13735 }
13736
13737 self.finish_observation("syncular_window", batch)
13738 })();
13739 if let Err(error) = widened {
13740 self.rollback_observation("syncular_window");
13741 self.subs = prior_subs;
13742 return Err(error);
13743 }
13744 for (unit, sub_id) in live {
13745 if wanted.contains(&unit) {
13746 continue;
13747 }
13748 self.rebuild_overlay_if_dirty()?;
13749 let prior_subs = self.subs.clone();
13750 self.begin_observation("syncular_window")?;
13751 let effective = self
13752 .subs
13753 .iter()
13754 .find(|sub| sub.id == sub_id)
13755 .and_then(|sub| sub.effective.clone())
13756 .unwrap_or_else(|| unit_scopes(base, &unit));
13757 let mut batch = ChangeAccumulator::default();
13758 self.record_scope_map(&mut batch, &base.table, &effective);
13759 batch.window(&base_key, &base.table, &unit);
13760 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
13761 let remaining = match result {
13762 Ok(remaining) => remaining,
13763 Err(error) => {
13764 self.rollback_observation("syncular_window");
13765 self.subs = prior_subs;
13766 return Err(error);
13767 }
13768 };
13769 if let Err(error) = self.finish_observation("syncular_window", batch) {
13770 self.rollback_observation("syncular_window");
13771 self.subs = prior_subs;
13772 return Err(error);
13773 }
13774 if remaining {
13775 self.drain_pending_evictions()?;
13776 }
13777 changed = true;
13778 }
13779 Ok(if changed {
13780 CommandEffects::interactive()
13781 } else {
13782 CommandEffects::none()
13783 })
13784 }
13785
13786 pub fn window_state(&self, base: &WindowBase) -> WindowState {
13791 let mut units = Vec::new();
13792 let mut pending = Vec::new();
13793 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
13794 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
13795 Some(sub) => {
13796 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
13797 }
13798 None => true,
13799 };
13800 if is_pending {
13801 pending.push(unit.clone());
13802 }
13803 units.push(unit);
13804 }
13805 WindowState { units, pending }
13806 }
13807
13808 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
13809 let mut stmt = match self
13810 .conn
13811 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
13812 {
13813 Ok(stmt) => stmt,
13814 Err(_) => return Vec::new(),
13815 };
13816 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
13817 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
13818 });
13819 match rows {
13820 Ok(rows) => rows.filter_map(Result::ok).collect(),
13821 Err(_) => Vec::new(),
13822 }
13823 }
13824
13825 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
13826 let mut stmt = match self.conn.prepare(
13827 "SELECT windows.base, windows.unit, subscriptions.tbl
13828 FROM _syncular_windows AS windows
13829 JOIN _syncular_subscriptions AS subscriptions
13830 ON subscriptions.id = windows.sub_id
13831 ORDER BY windows.base, windows.unit",
13832 ) {
13833 Ok(stmt) => stmt,
13834 Err(_) => return Vec::new(),
13835 };
13836 let rows = stmt.query_map([], |row| {
13837 Ok((
13838 row.get::<_, String>(0)?,
13839 row.get::<_, String>(1)?,
13840 row.get::<_, String>(2)?,
13841 ))
13842 });
13843 match rows {
13844 Ok(rows) => rows.filter_map(Result::ok).collect(),
13845 Err(_) => Vec::new(),
13846 }
13847 }
13848
13849 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
13850 self.conn
13851 .query_row(
13852 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
13853 rusqlite::params![sub_id],
13854 |row| Ok((row.get(0)?, row.get(1)?)),
13855 )
13856 .ok()
13857 }
13858
13859 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
13860 self.conn
13861 .execute(
13862 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
13863 rusqlite::params![base_key, unit, sub_id],
13864 )
13865 .map(|_| ())
13866 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13867 }
13868
13869 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
13870 self.conn
13871 .execute(
13872 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
13873 rusqlite::params![base_key, unit],
13874 )
13875 .map(|_| ())
13876 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13877 }
13878
13879 fn evict_unit(
13886 &mut self,
13887 base_key: &str,
13888 base: &WindowBase,
13889 unit: &str,
13890 sub_id: &str,
13891 ) -> Result<bool, String> {
13892 let effective = self
13893 .subs
13894 .iter()
13895 .find(|s| s.id == sub_id)
13896 .and_then(|s| s.effective.clone())
13897 .unwrap_or_else(|| unit_scopes(base, unit));
13898 let pinned = self.pinned_row_ids(&base.table);
13899 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
13900 self.delete_window_unit(base_key, unit)?;
13901 self.conn
13902 .execute(
13903 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
13904 [sub_id],
13905 )
13906 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13907 self.subs.retain(|sub| sub.id != sub_id);
13908 if deferred {
13909 self.save_pending_evict(sub_id, &base.table, &effective)?;
13910 } else {
13911 self.delete_pending_evict(sub_id)?;
13912 }
13913 Ok(remaining)
13914 }
13915
13916 fn evict_scope_rows(
13920 &self,
13921 table_name: &str,
13922 effective: &[(String, Vec<String>)],
13923 pinned: &std::collections::HashSet<String>,
13924 ) -> Result<(bool, bool), String> {
13925 if effective.is_empty() {
13926 return Ok((false, false));
13927 }
13928 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
13929 let mut clauses = Vec::new();
13930 let mut params: Vec<SqlValue> = Vec::new();
13931 for (variable, values) in effective {
13932 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
13933 if values.is_empty() {
13934 return Ok((false, false));
13935 }
13936 let holes = values
13937 .iter()
13938 .map(|v| {
13939 params.push(SqlValue::Text(v.clone()));
13940 "?"
13941 })
13942 .collect::<Vec<_>>()
13943 .join(", ");
13944 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
13945 }
13946 let scope = clauses.join(" AND ");
13947 let scope_params = params.len();
13948 let pk = quote_ident(&table.primary_key);
13949 if !pinned.is_empty() {
13950 let holes = pinned
13951 .iter()
13952 .map(|id| {
13953 params.push(SqlValue::Text(id.clone()));
13954 "?"
13955 })
13956 .collect::<Vec<_>>()
13957 .join(", ");
13958 clauses.push(format!("{pk} NOT IN ({holes})"));
13959 }
13960 let predicate = clauses.join(" AND ");
13961 let base = base_table(table_name);
13962 let visible = visible_table(table_name);
13963 let mut ids: Vec<SqlValue> = Vec::new();
13964 for source in [&base, &visible] {
13965 let mut stmt = self
13966 .conn
13967 .prepare_cached(&format!(
13968 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
13969 ))
13970 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13971 ids = stmt
13972 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
13973 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13974 .collect::<Result<_, _>>()
13975 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13976 if !ids.is_empty() {
13977 break;
13978 }
13979 }
13980 if !ids.is_empty() {
13981 for id in &ids {
13982 self.conn
13983 .execute(
13984 "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
13985 rusqlite::params![table_name, id],
13986 )
13987 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13988 }
13989 let holes = vec!["?"; ids.len()].join(", ");
13990 for source in [&base, &visible] {
13991 self.conn
13992 .execute(
13993 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
13994 rusqlite::params_from_iter(&ids),
13995 )
13996 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13997 }
13998 }
13999 let mut remaining = false;
14000 let mut deferred = false;
14001 for source in [&base, &visible] {
14002 remaining |= self
14003 .conn
14004 .query_row(
14005 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
14006 rusqlite::params_from_iter(¶ms),
14007 |row| row.get::<_, bool>(0),
14008 )
14009 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14010 deferred |= self
14011 .conn
14012 .query_row(
14013 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
14014 rusqlite::params_from_iter(¶ms[..scope_params]),
14015 |row| row.get::<_, bool>(0),
14016 )
14017 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14018 }
14019 Ok((remaining, deferred))
14020 }
14021
14022 fn drain_pending_evictions(&mut self) -> Result<(), String> {
14024 let pending = self.load_pending_evictions()?;
14025 if pending.is_empty() {
14026 return Ok(());
14027 }
14028 self.rebuild_overlay_if_dirty()?;
14029 for (sub_id, table_name, effective) in pending {
14030 if self.schema.table(&table_name).is_none() {
14031 self.delete_pending_evict(&sub_id)?;
14032 continue;
14033 }
14034 loop {
14035 std::thread::yield_now();
14036 self.begin_observation("syncular_evict")?;
14037 let pinned = self.pinned_row_ids(&table_name);
14038 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
14039 let (remaining, deferred) = match result {
14040 Ok(result) => result,
14041 Err(error) => {
14042 self.rollback_observation("syncular_evict");
14043 return Err(error);
14044 }
14045 };
14046 if !deferred {
14047 if let Err(error) = self.delete_pending_evict(&sub_id) {
14048 self.rollback_observation("syncular_evict");
14049 return Err(error);
14050 }
14051 }
14052 let mut batch = ChangeAccumulator::default();
14053 self.record_scope_map(&mut batch, &table_name, &effective);
14054 if let Err(error) = self.finish_observation("syncular_evict", batch) {
14055 self.rollback_observation("syncular_evict");
14056 return Err(error);
14057 }
14058 if !remaining {
14059 break;
14060 }
14061 }
14062 }
14063 Ok(())
14064 }
14065
14066 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
14069 let mut pinned = std::collections::HashSet::new();
14070 for commit in self
14071 .outbox
14072 .iter()
14073 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
14074 {
14075 for op in &commit.ops {
14076 if op.table == table {
14077 pinned.insert(op.row_id.clone());
14078 }
14079 }
14080 }
14081 pinned
14082 }
14083
14084 fn save_pending_evict(
14085 &self,
14086 sub_id: &str,
14087 table: &str,
14088 effective: &[(String, Vec<String>)],
14089 ) -> Result<(), String> {
14090 self.conn.execute(
14091 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
14092 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
14093 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14094 }
14095
14096 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
14097 self.conn
14098 .execute(
14099 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
14100 rusqlite::params![sub_id],
14101 )
14102 .map(|_| ())
14103 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14104 }
14105
14106 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
14107 let mut stmt = self
14108 .conn
14109 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
14110 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14111 let rows = stmt
14112 .query_map([], |row| {
14113 Ok((
14114 row.get::<_, String>(0)?,
14115 row.get::<_, String>(1)?,
14116 row.get::<_, String>(2)?,
14117 ))
14118 })
14119 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14120 rows.map(|row| {
14121 let (sub_id, table, json) =
14122 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14123 let value: Value = serde_json::from_str(&json)
14124 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
14125 let effective = json_to_scope_map(&value)
14126 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
14127 Ok((sub_id, table, effective))
14128 })
14129 .collect()
14130 }
14131
14132 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
14134 self.retain_failed_commits = enabled;
14135 for (commit, _) in &self.failed_commits {
14136 for operation in &commit.ops {
14137 self.overlay_dirty.table(&operation.table);
14138 }
14139 }
14140 self.failed_commits = self.load_failed_commits()?;
14141 for (commit, _) in &self.failed_commits {
14142 for operation in &commit.ops {
14143 self.overlay_dirty.table(&operation.table);
14144 }
14145 }
14146 Ok(())
14147 }
14148
14149 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
14150 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
14151 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14152 let rows = statement
14153 .query_map([], |row| {
14154 Ok((
14155 row.get::<_, String>(0)?,
14156 row.get::<_, String>(1)?,
14157 row.get::<_, String>(2)?,
14158 ))
14159 })
14160 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14161 rows.map(|row| {
14162 let (id, operations, initial) =
14163 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14164 let operations: Vec<CommitOperation> =
14165 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
14166 let initial =
14167 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
14168 Ok((
14169 OutboxCommit {
14170 client_commit_id: id,
14171 ops: operations
14172 .into_iter()
14173 .map(|op| OutboxOp {
14174 upsert: op.op == "upsert",
14175 table: op.table,
14176 row_id: op.row_id,
14177 base_version: op.base_version,
14178 values: op.values,
14179 })
14180 .collect(),
14181 },
14182 initial,
14183 ))
14184 })
14185 .collect()
14186 }
14187
14188 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
14189 let mut initial = Map::new();
14190 for operation in operations {
14191 let table = self
14192 .schema
14193 .table(&operation.table)
14194 .ok_or_else(|| "sync.unknown_table".to_owned())?;
14195 let initial_row = match self.stored_row(table, &operation.row_id, true)? {
14196 Some(row) => Some(row),
14197 None => self.visible_row(table, &operation.row_id)?,
14198 };
14199 if let Some((values, _)) = initial_row {
14200 let mut intended: Map<String, Value> = table
14201 .columns
14202 .iter()
14203 .zip(values.iter())
14204 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14205 .collect();
14206 if let Some(patch) = &operation.values {
14207 intended.extend(patch.clone());
14208 }
14209 initial.insert(
14210 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14211 .map_err(|_| "sync.local_corrupt".to_owned())?,
14212 Value::Object(intended),
14213 );
14214 }
14215 }
14216 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
14217 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
14218 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
14219 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14220 self.failed_commits.push((
14221 OutboxCommit {
14222 client_commit_id: id.to_owned(),
14223 ops: operations.to_vec(),
14224 },
14225 initial,
14226 ));
14227 Ok(())
14228 }
14229
14230 fn drop_failed_commits(
14231 &mut self,
14232 matches: impl Fn(&str, &Map<String, Value>) -> bool,
14233 ) -> Result<Vec<OutboxCommit>, String> {
14234 let mut doomed = self.drop_acknowledged_rows(&matches)?;
14235 for (commit, initial) in &self.failed_commits {
14236 for operation in &commit.ops {
14237 let key =
14238 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14239 .map_err(|_| "sync.local_corrupt".to_owned())?;
14240 if initial
14241 .get(&key)
14242 .and_then(Value::as_object)
14243 .is_some_and(|values| matches(&operation.table, values))
14244 {
14245 doomed.push(commit.clone());
14246 break;
14247 }
14248 }
14249 }
14250 for commit in &doomed {
14251 self.conn
14252 .execute(
14253 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
14254 rusqlite::params![commit.client_commit_id],
14255 )
14256 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14257 }
14258 for commit in &doomed {
14259 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))?;
14260 }
14261 self.conflicts.retain(|record| {
14262 !doomed
14263 .iter()
14264 .any(|commit| commit.client_commit_id == record.client_commit_id)
14265 });
14266 self.rejections.retain(|record| {
14267 !doomed
14268 .iter()
14269 .any(|commit| commit.client_commit_id == record.client_commit_id)
14270 });
14271 self.failed_commits.retain(|(commit, _)| {
14272 !doomed
14273 .iter()
14274 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
14275 });
14276 for commit in &doomed {
14277 for operation in &commit.ops {
14278 self.overlay_dirty.table(&operation.table);
14279 }
14280 }
14281 Ok(doomed)
14282 }
14283
14284 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
14288 let prior = self.storage_failure.borrow_mut().take();
14289 let outcome = (|| -> Result<String, String> {
14290 if mutations.is_empty() {
14291 return Err(
14292 "sync.invalid_request: a commit must contain at least one operation (§6.1)"
14293 .to_owned(),
14294 );
14295 }
14296 let mut ops = Vec::with_capacity(mutations.len());
14297 for mutation in mutations {
14298 match mutation {
14299 Mutation::Patch {
14300 table,
14301 values,
14302 base_version,
14303 } => {
14304 let schema_table = self
14305 .schema
14306 .table(&table)
14307 .ok_or_else(|| "sync.unknown_table".to_owned())?;
14308 let values = normalize_values_casing(schema_table, values)?;
14309 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
14310 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
14311 }
14312 Mutation::Upsert {
14313 table,
14314 values,
14315 base_version,
14316 } => {
14317 let schema_table = self.schema.table(&table).ok_or_else(|| {
14318 format!("sync.unknown_table: unknown table {table:?}")
14319 })?;
14320 let values = normalize_values_casing(schema_table, values)?;
14324 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
14325 let values = full_row_values(schema_table, values)?;
14328 self.validate_author_encode(schema_table, &row_id, &values)?;
14334 ops.push(OutboxOp {
14335 upsert: true,
14336 table,
14337 row_id,
14338 base_version,
14339 values: Some(values),
14340 });
14341 }
14342 Mutation::Delete {
14343 table,
14344 row_id,
14345 base_version,
14346 } => {
14347 if self.schema.table(&table).is_none() {
14348 return Err(format!("sync.unknown_table: unknown table {table:?}"));
14349 }
14350 ops.push(OutboxOp {
14351 upsert: false,
14352 table,
14353 row_id,
14354 base_version,
14355 values: None,
14356 });
14357 }
14358 }
14359 }
14360 self.record_outbox_commit(ops)
14361 })();
14362 self.finish_authoring(prior, outcome)
14363 }
14364
14365 fn finish_authoring(
14371 &self,
14372 prior: Option<QueryReadFailure>,
14373 outcome: Result<String, String>,
14374 ) -> Result<String, ClientError> {
14375 let fresh = self.storage_failure.borrow_mut().take();
14376 *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
14377 match outcome {
14378 Ok(id) => Ok(id),
14379 Err(message) => Err(fresh
14380 .map(ClientError::from)
14381 .unwrap_or_else(|| ClientError::from(message))),
14382 }
14383 }
14384
14385 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
14386 let commit = OutboxCommit {
14387 client_commit_id: uuid::Uuid::new_v4().to_string(),
14388 ops,
14389 };
14390 self.begin_observation("syncular_mutation")?;
14391 let mut batch = ChangeAccumulator::default();
14392 for op in &commit.ops {
14393 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
14394 if let Some(values) = &op.values {
14395 if let Some(table) = self.schema.table(&op.table) {
14396 for scope in &table.scope_variables {
14397 if let Some(Value::String(value)) = values.get(&scope.column) {
14398 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
14399 precise = true;
14400 }
14401 }
14402 }
14403 }
14404 if !precise {
14405 batch.table(&op.table);
14406 }
14407 }
14408 if let Err(error) = self.persist_outbox_insert(&commit) {
14409 self.rollback_observation("syncular_mutation");
14410 return Err(error);
14411 }
14412 let was_dirty = self.overlay_dirty.snapshot();
14413 if let Err(error) = self.rebuild_overlay_if_dirty() {
14418 self.rollback_observation("syncular_mutation");
14419 self.overlay_dirty.restore(was_dirty.clone());
14420 return Err(error);
14421 }
14422 if let Some(error) = commit
14423 .ops
14424 .iter()
14425 .find_map(|op| self.apply_outbox_op(op).err())
14426 {
14427 self.rollback_observation("syncular_mutation");
14428 self.overlay_dirty.restore(was_dirty.clone());
14429 return Err(error);
14430 }
14431 let id = commit.client_commit_id.clone();
14432 self.outbox.push(commit);
14433 batch.status = true;
14434 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
14435 self.rollback_observation("syncular_mutation");
14436 self.outbox.pop();
14437 self.overlay_dirty.restore(was_dirty.clone());
14438 return Err(error);
14439 }
14440 Ok(id)
14441 }
14442
14443 pub fn patch(
14449 &mut self,
14450 table: &str,
14451 row_id: &str,
14452 partial: Map<String, Value>,
14453 base_version: Option<i64>,
14454 ) -> Result<String, ClientError> {
14455 let prior = self.storage_failure.borrow_mut().take();
14456 let outcome = self.perform_patch(table, row_id, partial, base_version);
14457 self.finish_authoring(prior, outcome)
14458 }
14459
14460 fn perform_patch(
14463 &mut self,
14464 table: &str,
14465 row_id: &str,
14466 partial: Map<String, Value>,
14467 base_version: Option<i64>,
14468 ) -> Result<String, String> {
14469 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
14470 self.record_outbox_commit(vec![operation])
14471 }
14472
14473 fn prepare_patch(
14474 &self,
14475 table: &str,
14476 row_id: &str,
14477 partial: Map<String, Value>,
14478 base_version: Option<i64>,
14479 ) -> Result<OutboxOp, String> {
14480 let schema_table = self
14481 .schema
14482 .table(table)
14483 .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
14484 let partial = normalize_values_casing(schema_table, partial)?;
14485 if let Some(pk) = partial.get(&schema_table.primary_key) {
14486 if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
14487 return Err(format!(
14488 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
14489 ));
14490 }
14491 }
14492 let exists: bool = self
14495 .conn
14496 .query_row(
14497 &format!(
14498 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
14499 visible_table(table),
14500 row_id_predicate(schema_table)
14501 ),
14502 rusqlite::params![row_id],
14503 |row| row.get(0),
14504 )
14505 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14506 if !exists {
14507 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
14508 }
14509 for scope in &schema_table.scope_variables {
14519 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
14520 continue;
14521 }
14522 let Some(column) = schema_table
14523 .columns
14524 .iter()
14525 .find(|column| column.name == scope.column)
14526 else {
14527 continue;
14528 };
14529 let stored = self
14530 .conn
14531 .query_row(
14532 &format!(
14533 "SELECT {} FROM {} WHERE {}",
14534 quote_ident(&scope.column),
14535 visible_table(&schema_table.name),
14536 row_id_predicate(schema_table)
14537 ),
14538 rusqlite::params![row_id],
14539 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
14540 )
14541 .optional()
14542 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14543 let incoming = json_to_column_value(column, partial.get(&scope.column))
14548 .ok()
14549 .map(|value| column_value_to_json(&value));
14550 let equal = stored.as_ref().is_some_and(|stored| {
14554 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
14555 });
14556 if !equal {
14557 return Err(format!(
14558 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
14559 scope.column
14560 ));
14561 }
14562 }
14563 let mut values = partial;
14564 for scope in &schema_table.scope_variables {
14567 values.remove(&scope.column);
14568 }
14569 let pk_value = match schema_table.columns[schema_table.pk_index].ty {
14570 ColumnType::Integer => match row_id.parse::<i64>() {
14572 Ok(v)
14573 if v.to_string() == row_id
14574 && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
14575 .contains(&v) =>
14576 {
14577 Value::from(v)
14578 }
14579 _ => {
14580 return Err(format!(
14581 "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
14582 ))
14583 }
14584 },
14585 ColumnType::Boolean => match row_id {
14586 "true" => Value::Bool(true),
14587 "false" => Value::Bool(false),
14588 _ => {
14589 return Err(format!(
14590 "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
14591 ))
14592 }
14593 },
14594 _ => Value::from(row_id.to_owned()),
14595 };
14596 values.insert(schema_table.primary_key.clone(), pk_value);
14597 self.validate_author_encode(schema_table, row_id, &values)?;
14603 Ok(OutboxOp {
14604 upsert: true,
14605 table: table.to_owned(),
14606 row_id: row_id.to_owned(),
14607 base_version,
14608 values: Some(values),
14609 })
14610 }
14611
14612 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
14617 let selector = self.encryption.key_id_columns.get(&table.name)?;
14618 let index = table
14619 .columns
14620 .iter()
14621 .position(|column| &column.name == selector)?;
14622 let sql = format!(
14623 "SELECT {} FROM {} WHERE {}",
14624 quote_ident(selector),
14625 visible_table(&table.name),
14626 row_id_predicate(table)
14627 );
14628 let key: Option<String> = self
14629 .conn
14630 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
14631 .ok()?;
14632 let key = key.filter(|key| !key.is_empty())?;
14633 let mut fallback: Row = vec![None; table.columns.len()];
14634 fallback[index] = Some(ColumnValue::String(key));
14635 Some(fallback)
14636 }
14637
14638 fn validate_author_encode(
14643 &self,
14644 table: &TableSchema,
14645 row_id: &str,
14646 values: &Map<String, Value>,
14647 ) -> Result<(), String> {
14648 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
14649 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
14650 result => result.map(|_| ()),
14651 }
14652 }
14653
14654 fn drop_unencodable_outbox(
14668 &mut self,
14669 with_encryption: bool,
14670 batch: Option<&mut ChangeAccumulator>,
14671 ) -> Result<bool, String> {
14672 let failures = self
14673 .outbox
14674 .iter()
14675 .filter_map(|commit| {
14676 commit
14677 .ops
14678 .iter()
14679 .find_map(|op| {
14680 let values = op.values.as_ref().filter(|_| op.upsert)?;
14681 let table = self.schema.table(&op.table)?;
14682 let fallback = self.stored_key_fallback(table, &op.row_id);
14683 match encode_sparse_row_json(
14684 table,
14685 &op.row_id,
14686 values,
14687 &self.encryption,
14688 fallback.as_ref(),
14689 ) {
14690 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
14691 with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
14692 }
14693 Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
14698 LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
14699 OUTBOX_INCOMPATIBLE_CODE,
14700 )),
14701 _ => None,
14702 }
14703 })
14704 .map(|(error, code)| (commit.clone(), error, code))
14705 })
14706 .collect::<Vec<_>>();
14707 if failures.is_empty() {
14708 return Ok(false);
14709 }
14710 let prior_outbox = self.outbox.clone();
14716 let prior_rejections = self.rejections.clone();
14717 let prior_failed_commits = self.failed_commits.clone();
14721 let prior_changes = self.change_queue.len();
14722 let prior_intents = self.sync_intent_queue.len();
14723 let prior_last_change = self.last_change.clone();
14724 let prior_overlay_dirty = self.overlay_dirty.snapshot();
14725 let owns_observation = batch.is_none();
14726 let owns_transaction = self.conn.is_autocommit();
14727 let mut domains = ChangeAccumulator::default();
14728 if owns_observation {
14729 self.begin_observation("syncular_value_recovery")?;
14730 }
14731 let dropped = (|| -> Result<(), String> {
14732 for (commit, message, code) in failures {
14733 if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
14734 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
14735 }
14736 let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
14737 let rejection = RejectionRecord {
14738 client_commit_id: commit.client_commit_id.clone(),
14739 op_index: 0,
14740 code: code.to_owned(),
14741 message,
14742 retryable: false,
14743 details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
14744 field_paths: None,
14745 reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
14746 required_action: None,
14747 references: None,
14748 }),
14749 operation: representable
14750 .then(|| commit.ops.first())
14751 .flatten()
14752 .map(CommitOperation::from),
14753 };
14754 self.persist_commit_outcome(
14755 &commit.client_commit_id,
14756 CommitOutcomeStatus::Rejected,
14757 &[CommitOperationOutcome::Error {
14758 rejection: rejection.clone(),
14759 }],
14760 representable.then_some(commit.ops.as_slice()),
14761 )?;
14762 self.delete_outbox_persisted(&commit.client_commit_id)?;
14763 self.outbox
14764 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
14765 self.rejections.push(rejection);
14766 for operation in &commit.ops {
14767 domains.table(operation.table.as_str());
14768 }
14769 }
14770 domains.status = true;
14771 domains.rejections = true;
14772 domains.outcomes = true;
14773 self.prune_commit_outcomes()?;
14774 self.overlay_dirty.set(true);
14776 self.rebuild_overlay()?;
14777 Ok(())
14778 })();
14779 let result = match batch {
14780 Some(batch) => {
14781 if dropped.is_ok() {
14782 for table in domains.tables.keys() {
14783 batch.table(table.as_str());
14784 }
14785 batch.status |= domains.status;
14786 batch.rejections |= domains.rejections;
14787 batch.outcomes |= domains.outcomes;
14788 }
14789 dropped
14790 }
14791 None => {
14792 dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
14793 }
14794 };
14795 if let Err(error) = result {
14796 if owns_observation {
14797 if owns_transaction {
14798 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
14799 let mut failure = self.storage_failure.borrow_mut();
14800 let failure =
14801 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
14802 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
14803 }
14804 } else {
14805 self.rollback_observation("syncular_value_recovery");
14806 }
14807 }
14808 self.outbox = prior_outbox;
14809 self.rejections = prior_rejections;
14810 self.failed_commits = prior_failed_commits;
14811 self.change_queue.truncate(prior_changes);
14812 self.sync_intent_queue.truncate(prior_intents);
14813 self.last_change = prior_last_change;
14814 self.overlay_dirty.restore(prior_overlay_dirty);
14815 return Err(error);
14816 }
14817 Ok(true)
14818 }
14819
14820 pub fn pending_commit_ids(&self) -> Vec<String> {
14821 self.outbox
14822 .iter()
14823 .map(|c| c.client_commit_id.clone())
14824 .collect()
14825 }
14826
14827 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
14831 let mut payloads = Vec::new();
14832 for commit in &self.outbox {
14833 for op in &commit.ops {
14834 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
14835 continue;
14836 };
14837 let Some(table) = self.schema.table(&op.table) else {
14838 continue;
14839 };
14840 let fallback = self.stored_key_fallback(table, &op.row_id);
14841 if let Ok(payload) = encode_sparse_row_json(
14842 table,
14843 &op.row_id,
14844 values,
14845 &self.encryption,
14846 fallback.as_ref(),
14847 ) {
14848 payloads.push(payload);
14849 }
14850 }
14851 }
14852 payloads
14853 }
14854
14855 pub fn conflicts(&self) -> &[ConflictRecord] {
14856 &self.conflicts
14857 }
14858
14859 pub fn rejections(&self) -> &[RejectionRecord] {
14860 &self.rejections
14861 }
14862
14863 pub fn resolve_commit_outcome(
14864 &mut self,
14865 input: ResolveCommitOutcomeInput,
14866 ) -> Result<CommitOutcome, String> {
14867 let current = self
14868 .commit_outcome(&input.client_commit_id)?
14869 .ok_or_else(|| {
14870 format!(
14871 "sync.outcome_not_found: no durable outcome exists for {:?}",
14872 input.client_commit_id
14873 )
14874 })?;
14875 if current.resolution != CommitOutcomeResolution::Active {
14876 return Ok(current);
14877 }
14878 if input.resolution == CommitOutcomeResolution::Active {
14879 return Err("sync.invalid_request: resolution must leave active state".to_owned());
14880 }
14881 match input.resolution {
14882 CommitOutcomeResolution::Superseded => {
14883 let replacement = input
14884 .replacement_client_commit_id
14885 .as_deref()
14886 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
14887 .ok_or_else(|| {
14888 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
14889 .to_owned()
14890 })?;
14891 let _ = replacement;
14892 }
14893 _ if input.replacement_client_commit_id.is_some() => {
14894 return Err(
14895 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
14896 .to_owned(),
14897 );
14898 }
14899 _ => {}
14900 }
14901 let allowed = match current.status {
14902 CommitOutcomeStatus::Conflict => matches!(
14903 input.resolution,
14904 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
14905 ),
14906 CommitOutcomeStatus::Rejected => {
14907 matches!(
14908 input.resolution,
14909 CommitOutcomeResolution::Superseded
14910 | CommitOutcomeResolution::ResolvedKeepServer
14911 )
14912 }
14913 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
14914 input.resolution == CommitOutcomeResolution::Dismissed
14915 }
14916 };
14917 if !allowed {
14918 return Err(format!(
14919 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
14920 input.resolution, current.status
14921 ));
14922 }
14923
14924 self.begin_observation("syncular_outcome_resolution")?;
14925 let previous_failed = self.failed_commits.clone();
14926 let previous_conflicts = self.conflicts.clone();
14927 let previous_rejections = self.rejections.clone();
14928 let result = (|| {
14929 self.conn
14930 .execute(
14931 "UPDATE _syncular_commit_outcomes
14932 SET resolution = ?1, resolved_at_ms = ?2,
14933 replacement_client_commit_id = ?3
14934 WHERE client_commit_id = ?4 AND resolution = 'active'",
14935 rusqlite::params![
14936 Self::outcome_resolution_name(input.resolution),
14937 self.clock_now_ms(),
14938 input.replacement_client_commit_id,
14939 input.client_commit_id
14940 ],
14941 )
14942 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14943 let resolved = self
14944 .commit_outcome(¤t.client_commit_id)?
14945 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
14946 self.conn
14947 .execute(
14948 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
14949 rusqlite::params![input.client_commit_id],
14950 )
14951 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14952 for (commit, _) in &self.failed_commits {
14953 if commit.client_commit_id == input.client_commit_id {
14954 for operation in &commit.ops {
14955 self.overlay_dirty.table(&operation.table);
14956 }
14957 }
14958 }
14959 self.failed_commits
14960 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
14961 self.prune_commit_outcomes()?;
14962 Ok(resolved)
14963 })();
14964 let resolved = match result {
14965 Ok(outcome) => outcome,
14966 Err(error) => {
14967 self.rollback_observation("syncular_outcome_resolution");
14968 self.failed_commits = previous_failed;
14969 self.conflicts = previous_conflicts;
14970 self.rejections = previous_rejections;
14971 return Err(error);
14972 }
14973 };
14974 self.conflicts
14975 .retain(|record| record.client_commit_id != current.client_commit_id);
14976 self.rejections
14977 .retain(|record| record.client_commit_id != current.client_commit_id);
14978 let mut batch = ChangeAccumulator {
14979 conflicts: current.status == CommitOutcomeStatus::Conflict,
14980 rejections: current.status == CommitOutcomeStatus::Rejected,
14981 outcomes: true,
14982 ..ChangeAccumulator::default()
14983 };
14984 if let Some(operations) = ¤t.operations {
14985 for operation in operations {
14986 batch.table(&operation.table);
14987 }
14988 }
14989 if let Err(error) = self
14990 .rebuild_overlay_if_dirty()
14991 .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
14992 {
14993 self.rollback_observation("syncular_outcome_resolution");
14994 self.failed_commits = previous_failed;
14995 self.conflicts = previous_conflicts;
14996 self.rejections = previous_rejections;
14997 return Err(error);
14998 }
14999 Ok(resolved)
15000 }
15001
15002 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
15003 self.schema_floor.as_ref()
15004 }
15005
15006 pub fn lease_state(&self) -> Option<&LeaseState> {
15008 self.lease_state.as_ref()
15009 }
15010
15011 fn record_lease_error(&mut self, code: &str) {
15014 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
15015 return;
15016 }
15017 let mut next = self.lease_state.clone().unwrap_or_default();
15018 next.error_code = Some(code.to_owned());
15019 self.set_lease_state(Some(next));
15020 }
15021
15022 fn set_lease_state(&mut self, next: Option<LeaseState>) {
15023 if self.lease_state == next {
15024 return;
15025 }
15026 if self.begin_observation("syncular_lease").is_err() {
15027 return;
15028 }
15029 self.lease_state = next;
15030 if let Some(lease) = &self.lease_state {
15031 if let Ok(json) = serde_json::to_string(lease) {
15032 self.set_meta(LEASE_STATE_KEY, &json);
15033 }
15034 } else {
15035 self.delete_meta(LEASE_STATE_KEY);
15036 }
15037 let batch = ChangeAccumulator {
15038 status: true,
15039 ..ChangeAccumulator::default()
15040 };
15041 if self.finish_observation("syncular_lease", batch).is_err() {
15042 self.rollback_observation("syncular_lease");
15043 }
15044 }
15045
15046 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
15047 if self.schema_floor == next {
15048 return;
15049 }
15050 if self.begin_observation("syncular_schema_floor").is_err() {
15051 return;
15052 }
15053 self.schema_floor = next;
15054 self.stopped = self.schema_floor.is_some();
15055 if let Some(floor) = &self.schema_floor {
15056 if let Ok(json) = serde_json::to_string(floor) {
15057 self.set_meta(SCHEMA_FLOOR_KEY, &json);
15058 }
15059 } else {
15060 self.delete_meta(SCHEMA_FLOOR_KEY);
15061 }
15062 let batch = ChangeAccumulator {
15063 status: true,
15064 ..ChangeAccumulator::default()
15065 };
15066 if self
15067 .finish_observation("syncular_schema_floor", batch)
15068 .is_err()
15069 {
15070 self.rollback_observation("syncular_schema_floor");
15071 }
15072 }
15073
15074 fn set_upgrading(&mut self, value: bool) {
15075 if self.upgrading == value {
15076 return;
15077 }
15078 if self.begin_observation("syncular_upgrading").is_err() {
15079 return;
15080 }
15081 self.upgrading = value;
15082 let batch = ChangeAccumulator {
15083 status: true,
15084 ..ChangeAccumulator::default()
15085 };
15086 if self
15087 .finish_observation("syncular_upgrading", batch)
15088 .is_err()
15089 {
15090 self.rollback_observation("syncular_upgrading");
15091 }
15092 }
15093
15094 pub fn sync_needed(&self) -> bool {
15095 self.sync_needed
15096 }
15097
15098 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
15099 let sub = self.subs.iter().find(|s| s.id == id)?;
15100 Some(SubscriptionStateView {
15101 id: sub.id.clone(),
15102 table: sub.table.clone(),
15103 status: sub.state.name().to_owned(),
15104 cursor: sub.cursor,
15105 has_resume_token: sub.bootstrap_state.is_some(),
15106 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
15107 reason_code: sub.reason_code.clone(),
15108 })
15109 }
15110
15111 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
15112 let schema_table = self
15113 .schema
15114 .table(table)
15115 .ok_or_else(|| format!("unknown table {table:?}"))?;
15116 let sql = format!(
15117 "SELECT * FROM {} ORDER BY {} ASC",
15118 visible_table(table),
15119 quote_ident(&schema_table.primary_key)
15120 );
15121 let mut stmt = self
15122 .conn
15123 .prepare(&sql)
15124 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15125 let mut rows = stmt
15126 .query([])
15127 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15128 let mut out = Vec::new();
15129 while let Some(row) = rows
15130 .next()
15131 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15132 {
15133 let mut values = Map::new();
15134 for (i, column) in schema_table.columns.iter().enumerate() {
15135 let value = row
15136 .get_ref(i)
15137 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15138 values.insert(column.name.clone(), sql_ref_to_json(column, value));
15139 }
15140 let version: i64 = row
15141 .get(schema_table.columns.len())
15142 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15143 let row_id = match values.get(&schema_table.primary_key) {
15144 Some(Value::String(s)) => s.clone(),
15145 Some(Value::Number(n)) => n.to_string(),
15146 Some(Value::Bool(b)) => b.to_string(),
15147 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
15148 };
15149 out.push(RowState {
15150 row_id,
15151 version,
15152 values,
15153 });
15154 }
15155 Ok(out)
15156 }
15157
15158 #[cfg(feature = "crdt-yjs")]
15174 fn crdt_column_bytes(
15175 &self,
15176 table: &str,
15177 row_id: &str,
15178 column: &str,
15179 ) -> Result<Option<Vec<u8>>, String> {
15180 let schema_table = self
15181 .schema
15182 .table(table)
15183 .ok_or_else(|| format!("unknown table {table:?}"))?;
15184 let col = schema_table
15185 .columns
15186 .iter()
15187 .find(|c| c.name == column)
15188 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
15189 if col.ty != ColumnType::Crdt {
15190 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
15191 }
15192 let sql = format!(
15193 "SELECT {} FROM {} WHERE {}",
15194 quote_ident(column),
15195 visible_table(table),
15196 row_id_predicate(schema_table)
15197 );
15198 let bytes: Option<Vec<u8>> = self
15199 .conn
15200 .prepare_cached(&sql)
15201 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15202 .query_row(rusqlite::params![row_id], |row| {
15203 row.get::<_, Option<Vec<u8>>>(0)
15204 })
15205 .map_err(|e| match e {
15206 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
15207 other => Self::sqlite_failure(&self.storage_failure, other),
15208 })?;
15209 Ok(bytes)
15210 }
15211
15212 #[cfg(feature = "crdt-yjs")]
15216 pub fn crdt_text(
15217 &self,
15218 table: &str,
15219 row_id: &str,
15220 column: &str,
15221 name: &str,
15222 ) -> Result<String, String> {
15223 let bytes = self
15224 .crdt_column_bytes(table, row_id, column)?
15225 .unwrap_or_default();
15226 crate::crdt::text(&bytes, name)
15227 }
15228
15229 #[cfg(feature = "crdt-yjs")]
15233 pub fn crdt_insert_text(
15234 &mut self,
15235 table: &str,
15236 row_id: &str,
15237 column: &str,
15238 name: &str,
15239 index: u32,
15240 value: &str,
15241 ) -> Result<String, ClientError> {
15242 let prior = self.storage_failure.borrow_mut().take();
15243 let outcome = (|| -> Result<String, String> {
15244 let current = self
15245 .crdt_column_bytes(table, row_id, column)?
15246 .unwrap_or_default();
15247 let update = crate::crdt::insert_text(¤t, name, index, value)?;
15248 self.perform_crdt_push_update(table, row_id, column, &update)
15249 })();
15250 self.finish_authoring(prior, outcome)
15251 }
15252
15253 #[cfg(feature = "crdt-yjs")]
15257 pub fn crdt_delete_text(
15258 &mut self,
15259 table: &str,
15260 row_id: &str,
15261 column: &str,
15262 name: &str,
15263 index: u32,
15264 len: u32,
15265 ) -> Result<String, ClientError> {
15266 let prior = self.storage_failure.borrow_mut().take();
15267 let outcome = (|| -> Result<String, String> {
15268 let current = self
15269 .crdt_column_bytes(table, row_id, column)?
15270 .unwrap_or_default();
15271 let update = crate::crdt::delete_text(¤t, name, index, len)?;
15272 self.perform_crdt_push_update(table, row_id, column, &update)
15273 })();
15274 self.finish_authoring(prior, outcome)
15275 }
15276
15277 #[cfg(feature = "crdt-yjs")]
15282 pub fn crdt_apply_update(
15283 &mut self,
15284 table: &str,
15285 row_id: &str,
15286 column: &str,
15287 update: &[u8],
15288 ) -> Result<String, ClientError> {
15289 let prior = self.storage_failure.borrow_mut().take();
15290 let outcome = (|| -> Result<String, String> {
15291 let current = self
15292 .crdt_column_bytes(table, row_id, column)?
15293 .unwrap_or_default();
15294 let next = crate::crdt::apply_update(¤t, update)?;
15295 self.perform_crdt_push_update(table, row_id, column, &next)
15296 })();
15297 self.finish_authoring(prior, outcome)
15298 }
15299
15300 #[cfg(feature = "crdt-yjs")]
15307 fn perform_crdt_push_update(
15308 &mut self,
15309 table: &str,
15310 row_id: &str,
15311 column: &str,
15312 crdt_bytes: &[u8],
15313 ) -> Result<String, String> {
15314 let mut bytes_obj = Map::new();
15315 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
15316 let mut partial = Map::new();
15317 partial.insert(column.to_owned(), Value::Object(bytes_obj));
15318 self.perform_patch(table, row_id, partial, None)
15319 }
15320
15321 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
15336 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
15337 }
15338
15339 #[cfg(feature = "bench-internals")]
15341 #[doc(hidden)]
15342 pub fn benchmark_connection(&mut self) -> &mut Connection {
15343 &mut self.conn
15344 }
15345
15346 #[cfg(feature = "bench-internals")]
15348 #[doc(hidden)]
15349 pub fn benchmark_phases(
15350 &self,
15351 enabled: Option<bool>,
15352 reset: bool,
15353 ) -> Result<Option<Value>, String> {
15354 self.benchmark_phases.configure(enabled, reset)?;
15355 Ok(self.benchmark_phases.snapshot())
15356 }
15357
15358 pub fn authority_snapshot(&self) -> Result<Value, String> {
15360 validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
15361 if self.limits.authority_reads.is_empty() {
15362 return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
15363 }
15364 self.conn
15365 .execute_batch("SAVEPOINT authority_snapshot")
15366 .map_err(|e| e.to_string())?;
15367 let result = (|| {
15368 let revision = self
15369 .get_meta(LOCAL_REVISION_KEY)
15370 .ok_or("sync.local_corrupt: authority revision is missing")?;
15371 let parsed_revision = revision
15372 .parse::<u64>()
15373 .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
15374 if parsed_revision.to_string() != revision {
15375 return Err("sync.local_corrupt: authority revision is invalid".into());
15376 }
15377 let mut tables = Vec::new();
15378 for read in &self.limits.authority_reads {
15379 let table = self
15380 .schema
15381 .table(&read.table)
15382 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
15383 let scalar = |column: &str, value: &Value| -> Result<String, String> {
15384 let ty = table
15385 .columns
15386 .iter()
15387 .find(|c| c.name == column)
15388 .expect("validated authority column")
15389 .ty;
15390 if ty == ColumnType::Boolean {
15391 if let Some(value) = value.as_bool() {
15392 return Ok(value.to_string());
15393 }
15394 return match value.as_i64() {
15395 Some(0) => Ok("false".into()),
15396 Some(1) => Ok("true".into()),
15397 _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
15398 };
15399 }
15400 match value {
15401 Value::String(s) => Ok(s.clone()),
15402 Value::Number(n) => Ok(n.to_string()),
15403 Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
15404 .get("$bigint")
15405 .and_then(Value::as_str)
15406 .map(str::to_owned)
15407 .ok_or("sync.local_corrupt: authority integer is invalid".into()),
15408 _ => {
15409 Err("sync.local_corrupt: authority scalar is missing or invalid".into())
15410 }
15411 }
15412 };
15413 let matches = |row: &QueryRow| -> Result<bool, String> {
15414 for (variable, values) in &read.scopes {
15415 let column = table
15416 .scope_column(variable)
15417 .expect("validated authority scope");
15418 let value = row
15419 .get(column)
15420 .ok_or("sync.local_corrupt: authority scope column is missing")?;
15421 if value.is_null() || !values.contains(&scalar(column, value)?) {
15422 return Ok(false);
15423 }
15424 }
15425 Ok(true)
15426 };
15427 let selected = read
15428 .columns
15429 .iter()
15430 .map(|c| quote_ident(c))
15431 .collect::<Vec<_>>()
15432 .join(",");
15433 let mut intent = BTreeSet::new();
15434 let mut relevant = BTreeSet::new();
15435 for row in query_connection(&self.conn,
15436 "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=?",
15437 &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
15438 let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
15439 intent.insert(id.clone());
15440 if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
15441 let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
15442 if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
15443 }
15444 }
15445 let mut rows = BTreeMap::new();
15446 for full in [base_table(&read.table), visible_table(&read.table)] {
15447 for mut values in query_connection(
15448 &self.conn,
15449 &format!(
15450 "SELECT {selected},_syncular_version AS authority_version FROM {full}"
15451 ),
15452 &[],
15453 )
15454 .map_err(|e| e.to_string())?
15455 {
15456 if !matches(&values)? {
15457 continue;
15458 }
15459 let id = scalar(
15460 &table.primary_key,
15461 values
15462 .get(&table.primary_key)
15463 .expect("selected primary key"),
15464 )?;
15465 relevant.insert(id.clone());
15466 if full == base_table(&read.table) {
15467 let version = values
15468 .remove("authority_version")
15469 .expect("selected version");
15470 let version = version
15471 .as_i64()
15472 .ok_or("sync.local_corrupt: authority server version is invalid")?;
15473 if version >= 0 {
15474 rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
15475 }
15476 }
15477 }
15478 }
15479 intent.retain(|id| relevant.contains(id));
15480 let rows = rows.into_values().collect::<Vec<_>>();
15481 let mut persisted = Vec::new();
15482 for row in query_connection(
15483 &self.conn,
15484 "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
15485 &[Value::String(read.table.clone())],
15486 )
15487 .map_err(|e| e.message)?
15488 {
15489 let state: Value = serde_json::from_str(
15490 row.get("state_json")
15491 .and_then(Value::as_str)
15492 .ok_or("sync.local_corrupt: authority subscription state is missing")?,
15493 )
15494 .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
15495 let effective = state.get("effectiveScopes").and_then(Value::as_object);
15496 let status = state
15497 .get("status")
15498 .and_then(Value::as_str)
15499 .ok_or("sync.local_corrupt: authority subscription status is missing")?;
15500 let cursor = state
15501 .get("cursor")
15502 .and_then(Value::as_i64)
15503 .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
15504 let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
15505 && status == "active"
15506 && cursor >= 0
15507 && state.get("bootstrapState").is_none_or(Value::is_null)
15508 && state.get("params").is_none_or(Value::is_null);
15509 let exposed = effective.map(|s| {
15510 read.scopes
15511 .keys()
15512 .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
15513 .collect::<Map<_, _>>()
15514 });
15515 let requested = read
15516 .scopes
15517 .keys()
15518 .map(|v| {
15519 (
15520 v.clone(),
15521 state
15522 .get("requested")
15523 .and_then(|s| s.get(v))
15524 .cloned()
15525 .unwrap_or(json!([])),
15526 )
15527 })
15528 .collect::<Map<_, _>>();
15529 persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
15530 }
15531 fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
15532 match selectors.split_first() {
15533 None => !candidates.is_empty(),
15534 Some(((variable, values), rest)) => values.iter().all(|value| {
15535 covered(
15536 rest,
15537 &candidates
15538 .iter()
15539 .copied()
15540 .filter(|p| {
15541 p["effectiveScopes"][variable.as_str()]
15542 .as_array()
15543 .is_some_and(|held| {
15544 held.contains(&Value::String(value.clone()))
15545 || held.contains(&Value::String("*".into()))
15546 })
15547 })
15548 .collect::<Vec<_>>(),
15549 )
15550 }),
15551 }
15552 }
15553 let coverage = if covered(
15554 &read.scopes.iter().collect::<Vec<_>>(),
15555 &persisted
15556 .iter()
15557 .filter(|p| p["complete"] == true)
15558 .collect::<Vec<_>>(),
15559 ) {
15560 "complete"
15561 } else if persisted.iter().any(|p| {
15562 p["status"] == "active"
15563 && read.scopes.iter().all(|(v, values)| {
15564 values.iter().any(|value| {
15565 p["requestedScopes"][v].as_array().is_some_and(|held| {
15566 held.contains(&Value::String(value.clone()))
15567 || held.contains(&Value::String("*".into()))
15568 })
15569 })
15570 })
15571 }) {
15572 "pending"
15573 } else {
15574 "missing"
15575 };
15576 tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
15577 }
15578 Ok(
15579 json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
15580 )
15581 })();
15582 match result {
15583 Ok(snapshot) => {
15584 self.conn
15585 .execute_batch("RELEASE authority_snapshot")
15586 .map_err(|e| e.to_string())?;
15587 Ok(snapshot)
15588 }
15589 Err(error) => {
15590 self.conn
15591 .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
15592 .map_err(|e| format!("{error}; rollback failed: {e}"))?;
15593 Err(error)
15594 }
15595 }
15596 }
15597
15598 pub fn query_snapshot(
15602 &mut self,
15603 sql: &str,
15604 params: &[QueryValue],
15605 coverage: &[WindowCoverage],
15606 owner: Option<QueryOwner<'_>>,
15607 ) -> Result<QuerySnapshot, String> {
15608 let result = snapshot_connection(&self.conn, sql, params, coverage);
15609 if let Some(owner) = owner {
15610 self.record_query_read(owner, result.as_ref().err());
15611 }
15612 result.map_err(|failure| failure.to_string())
15613 }
15614
15615 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
15619 let position = self
15620 .query_failures
15621 .iter()
15622 .position(|entry| entry.id == owner.id);
15623 let Some(failure) = failure else {
15624 if let Some(index) = position {
15625 self.query_failures.remove(index);
15626 }
15627 return;
15628 };
15629 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
15630 .into_iter()
15631 .map(str::to_owned)
15632 .collect();
15633 let code = failure.code.unwrap_or("client.query_failed").to_owned();
15634 if position.is_some_and(|index| {
15635 let entry = &self.query_failures[index];
15636 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
15637 }) {
15638 return;
15639 }
15640 if let Some(index) = position {
15641 self.query_failures.remove(index);
15642 }
15643 self.query_failures.push(DiagnosticQueryFailure {
15644 id: owner.id.to_owned(),
15645 tables,
15646 code,
15647 sqlite_code: failure.sqlite_code,
15648 at_ms: self.clock_now_ms(),
15649 });
15650 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
15651 self.query_failures.remove(0);
15652 }
15653 }
15654
15655 fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
15658 #[cfg(feature = "bench-internals")]
15659 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
15660 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
15661 let max_commits = self.limits.max_push_commits_per_request;
15662 let max_ops = self
15663 .limits
15664 .max_push_operations_per_request
15665 .unwrap_or(PUSH_OPS_PER_REQUEST);
15666 let byte_cap = self.limits.max_push_request_bytes;
15667 let accept = self.limits.accept.unwrap_or(if url_capable {
15670 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
15671 } else {
15672 DEFAULT_ACCEPT
15673 });
15674 let header = Frame::ReqHeader {
15675 client_id: self.client_id.clone(),
15676 schema_version: self.schema.version,
15677 log_epoch: log_epoch.clone(),
15678 };
15679 let pull = Frame::PullHeader {
15680 limit_commits: self.limits.limit_commits.unwrap_or(0),
15681 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
15682 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
15683 accept,
15684 };
15685 let mut fresh = Vec::new();
15686 let mut sub_frames = Vec::new();
15687 for sub in &self.subs {
15688 if sub.state != SubState::Active {
15689 continue;
15690 }
15691 let mut scopes = sub.requested.clone();
15692 sort_scope_map(&mut scopes);
15693 sub_frames.push(Frame::Subscription {
15694 id: sub.id.clone(),
15695 table: sub.table.clone(),
15696 scopes,
15697 params: sub.params.clone().map(RawJson),
15698 cursor: sub.cursor,
15699 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
15700 });
15701 fresh.push((
15702 sub.id.clone(),
15703 sub.cursor < 0 && sub.bootstrap_state.is_none(),
15704 ));
15705 }
15706 let fixed_bytes = match byte_cap {
15709 Some(_) => {
15710 let mut frames = vec![header.clone(), pull.clone()];
15711 frames.extend(sub_frames.iter().cloned());
15712 encode_message(&Message {
15713 wire_version: WIRE_VERSION,
15714 msg_kind: MsgKind::Request,
15715 frames,
15716 })
15717 .len()
15718 }
15719 None => 0,
15720 };
15721 if let Some(cap) = byte_cap {
15722 if fixed_bytes > cap {
15723 return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
15724 }
15725 }
15726 let base_bytes = match byte_cap {
15727 Some(_) => encode_message(&Message {
15728 wire_version: WIRE_VERSION,
15729 msg_kind: MsgKind::Request,
15730 frames: vec![header.clone(), pull.clone()],
15731 })
15732 .len(),
15733 None => 0,
15734 };
15735 let total_outbox = if log_epoch.is_some() {
15736 self.outbox.len()
15737 } else {
15738 0
15739 };
15740 let mut push_frames: Vec<Frame> = Vec::new();
15741 let mut pushed_ids = Vec::new();
15742 let mut ops_in_request = 0usize;
15743 let mut bytes_in_request = 0usize;
15744 let mut deferred_commits = 0usize;
15745 #[cfg(feature = "bench-internals")]
15746 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
15747 for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
15748 if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
15752 deferred_commits = total_outbox - index;
15753 break;
15754 }
15755 if ops_in_request + commit.ops.len() > max_ops {
15756 if push_frames.is_empty() {
15757 return Err(Self::push_capacity_outcome(
15758 "operations",
15759 max_ops,
15760 commit.ops.len(),
15761 Some(commit.client_commit_id.as_str()),
15762 ));
15763 }
15764 deferred_commits = total_outbox - index;
15765 break;
15766 }
15767 let operations = commit
15768 .ops
15769 .iter()
15770 .map(|op| {
15771 let payload = op.values.as_ref().and_then(|values| {
15772 let table = self.schema.table(&op.table)?;
15773 let fallback = self.stored_key_fallback(table, &op.row_id);
15782 encode_sparse_row_json(
15783 table,
15784 &op.row_id,
15785 values,
15786 &self.encryption,
15787 fallback.as_ref(),
15788 )
15789 .ok()
15790 });
15791 ssp2::model::Operation {
15792 table: op.table.clone(),
15793 row_id: op.row_id.clone(),
15794 op: if op.upsert { Op::Upsert } else { Op::Delete },
15795 base_version: op.base_version,
15796 payload,
15797 }
15798 })
15799 .collect();
15800 let frame = Frame::PushCommit {
15801 client_commit_id: commit.client_commit_id.clone(),
15802 operations,
15803 };
15804 let frame_bytes = match byte_cap {
15805 Some(_) => {
15806 encode_message(&Message {
15807 wire_version: WIRE_VERSION,
15808 msg_kind: MsgKind::Request,
15809 frames: vec![header.clone(), frame.clone(), pull.clone()],
15810 })
15811 .len()
15812 - base_bytes
15813 }
15814 None => 0,
15815 };
15816 if let Some(cap) = byte_cap {
15820 if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
15821 deferred_commits = total_outbox - index;
15822 break;
15823 }
15824 }
15825 push_frames.push(frame);
15826 pushed_ids.push(commit.client_commit_id.clone());
15827 ops_in_request += commit.ops.len();
15828 bytes_in_request += frame_bytes;
15829 }
15830 #[cfg(feature = "bench-internals")]
15831 drop(outbox_phase);
15832 if let Some(cap) = byte_cap {
15833 if fixed_bytes + bytes_in_request > cap {
15834 let blocked = if push_frames.is_empty() {
15837 None
15838 } else {
15839 self.outbox.first().map(|c| c.client_commit_id.as_str())
15840 };
15841 return Err(Self::push_capacity_outcome(
15842 "bytes",
15843 cap,
15844 fixed_bytes + bytes_in_request,
15845 blocked,
15846 ));
15847 }
15848 }
15849 let mut frames = vec![header];
15850 frames.extend(push_frames);
15851 frames.push(pull);
15852 frames.extend(sub_frames);
15853 let message = Message {
15854 wire_version: WIRE_VERSION,
15855 msg_kind: MsgKind::Request,
15856 frames,
15857 };
15858 Ok((
15859 message,
15860 RequestMeta {
15861 pushed_ids,
15862 fresh,
15863 accept,
15864 deferred_commits,
15865 },
15866 ))
15867 }
15868
15869 fn push_capacity_outcome(
15871 kind: &str,
15872 limit: usize,
15873 size: usize,
15874 client_commit_id: Option<&str>,
15875 ) -> Box<SyncOutcome> {
15876 let mut details = Map::new();
15877 details.insert("kind".to_owned(), Value::from(kind));
15878 details.insert("limit".to_owned(), Value::from(limit as u64));
15879 details.insert("size".to_owned(), Value::from(size as u64));
15880 if let Some(id) = client_commit_id {
15881 details.insert("clientCommitId".to_owned(), Value::from(id));
15882 }
15883 Box::new(SyncOutcome::Failed {
15884 error_code: "client.push_request_too_large".into(),
15885 message: "the push request exceeds a configured capacity limit".into(),
15886 details: Some(Value::Object(details)),
15887 })
15888 }
15889
15890 pub fn progress(&self) -> ProgressObserver {
15894 self.progress.clone()
15895 }
15896
15897 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
15898 let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
15899 Ok(prepared) => prepared,
15900 Err(outcome) => return *outcome,
15901 };
15902 let mut next = prepared;
15907 loop {
15908 match self.apply_sync_round(next.exchange(transport)) {
15909 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
15910 crate::AppliedSyncRound::Complete {
15911 outcome, controls, ..
15912 } => {
15913 if self.transport_enabled {
15914 for text in controls {
15915 let _ = transport.realtime_send(&text);
15916 }
15917 } else {
15918 self.disconnect_realtime(transport);
15919 }
15920 return outcome;
15921 }
15922 }
15923 }
15924 }
15925
15926 fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
15927 if let SyncOutcome::Failed {
15928 error_code,
15929 message,
15930 details,
15931 } = &mut outcome
15932 {
15933 if let Some(failure) = self.storage_failure.borrow().as_ref() {
15934 let code = failure
15935 .code
15936 .expect("retained storage failures are classified");
15937 *error_code = code.to_owned();
15938 *message = match code {
15939 "client.storage_busy" => "local SQLite storage is busy",
15940 "client.storage_full" => "local SQLite storage is full",
15941 "client.storage_io" => "local SQLite storage I/O failed",
15942 "client.storage_corrupt" => "local SQLite storage is corrupt",
15943 _ => unreachable!("unrecognized classified storage failure"),
15944 }
15945 .to_owned();
15946 *details = failure.details();
15947 }
15948 }
15949 let retry_delay_ms = self.round_retry_delay_ms;
15950 self.progress.update(|p| match &outcome {
15951 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
15952 if report.failed.is_empty() =>
15953 {
15954 p.state = ProgressState::Complete
15955 }
15956 SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
15957 p.state = ProgressState::Failed;
15958 p.error_code = Some("sync.scope_revoked".into());
15959 }
15960 SyncOutcome::Failed { error_code, .. } => {
15961 p.state = ProgressState::Failed;
15962 p.error_code = Some(Self::diagnostic_code(error_code));
15963 p.retry_delay_ms = retry_delay_ms;
15964 }
15965 SyncOutcome::RealtimeUnavailable { .. } => {
15966 p.state = ProgressState::Failed;
15967 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
15968 p.retry_delay_ms = retry_delay_ms;
15969 }
15970 });
15971 let completed_at_ms = self.clock_now_ms();
15972 self.last_round = Some(match &outcome {
15973 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
15974 status: "succeeded".to_owned(),
15975 started_at_ms,
15976 completed_at_ms,
15977 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15978 counters: Some(DiagnosticRoundCounters {
15979 pushed: report.pushed,
15980 applied: report.applied.len(),
15981 rejected: report.rejected.len(),
15982 retryable: report.retryable.len(),
15983 conflicts: report.conflicts,
15984 commits_applied: report.commits_applied,
15985 segment_rows_applied: report.segment_rows_applied,
15986 bootstrapping: report.bootstrapping.len(),
15987 resets: report.resets.len(),
15988 revoked: report.revoked.len(),
15989 failed: report.failed.len(),
15990 deferred_commits: report.deferred_commits,
15991 }),
15992 error_code: None,
15993 },
15994 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
15995 status: "failed".to_owned(),
15996 started_at_ms,
15997 completed_at_ms,
15998 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15999 counters: None,
16000 error_code: Some(Self::diagnostic_code(error_code)),
16001 },
16002 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
16003 status: "failed".to_owned(),
16004 started_at_ms,
16005 completed_at_ms,
16006 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16007 counters: None,
16008 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
16009 },
16010 });
16011 outcome
16012 }
16013
16014 pub fn prepare_sync_round(
16017 &mut self,
16018 url_capable: bool,
16019 ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
16020 if !self.transport_enabled {
16021 return Err(Box::new(SyncOutcome::Failed {
16022 error_code: "sync.offline".into(),
16023 message: "transport gate is closed".into(),
16024 details: None,
16025 }));
16026 }
16027 if self.active_round.is_some() {
16028 return Err(Box::new(SyncOutcome::Failed {
16029 error_code: "client.round_in_progress".into(),
16030 message: "a sync round is already in flight".into(),
16031 details: None,
16032 }));
16033 }
16034 self.storage_failure.borrow_mut().take();
16035 self.progress.start();
16036 self.round_retry_delay_ms = None;
16037 let started_at_ms = self.clock_now_ms();
16038 let prepared = (|| {
16039 if self.stopped {
16040 return Err(Box::new(SyncOutcome::Ok(SyncReport {
16041 schema_floor: self.schema_floor.clone(),
16042 ..SyncReport::default()
16043 })));
16044 }
16045 if self.security_preflight {
16046 return Err(Box::new(SyncOutcome::Failed {
16047 error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
16048 message: "security preflight must complete before syncing".into(),
16049 details: None,
16050 }));
16051 }
16052 self.set_sync_needed(false, false);
16053 self.drain_pending_evictions()
16054 .and_then(|_| self.drop_incompatible_outbox())
16055 .and_then(|_| self.drop_unencodable_outbox(true, None))
16056 .map_err(|message| {
16057 Box::new(SyncOutcome::Failed {
16058 error_code: "storage.failed".into(),
16059 message,
16060 details: None,
16061 })
16062 })?;
16063 let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
16064 self.pending_blob_uploads().map_err(|e| {
16065 Box::new(SyncOutcome::Failed {
16066 error_code: e.code,
16067 message: e.message,
16068 details: e.details,
16069 })
16070 })?
16071 } else {
16072 Vec::new()
16073 };
16074 if self.realtime_state != RealtimeState::Connected
16075 && self.realtime_policy == RealtimePolicy::Required
16076 {
16077 let retry_delay_ms = self.schedule_background_retry();
16078 self.realtime_retry_delay_ms = Some(retry_delay_ms);
16079 return Err(Box::new(SyncOutcome::RealtimeUnavailable {
16080 state: self.realtime_state,
16081 reason_code: self.realtime_reason_code.clone(),
16082 retry_delay_ms,
16083 }));
16084 }
16085 let (message, meta) = self.build_request(url_capable)?;
16086 let id = uuid::Uuid::new_v4();
16087 self.active_round = Some(id);
16088 Ok(crate::PreparedSyncRound {
16089 id,
16090 started_at_ms,
16091 message,
16092 meta,
16093 uploads: uploads.into(),
16094 realtime: self.realtime_state == RealtimeState::Connected,
16095 scopes: self
16096 .subs
16097 .iter()
16098 .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
16099 .collect(),
16100 #[cfg(feature = "bench-internals")]
16101 benchmark_phases: self.benchmark_phases.clone(),
16102 progress: self.progress.clone(),
16103 fixed_now: self.now_ms,
16104 round_deadline_at: None,
16105 })
16106 })();
16107 prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
16108 }
16109
16110 pub fn cancel_sync_round(&mut self) {
16113 self.active_round = None;
16114 }
16115
16116 pub fn apply_sync_round(
16119 &mut self,
16120 completed: crate::CompletedSyncRound,
16121 ) -> crate::AppliedSyncRound {
16122 let prepared = completed.prepared;
16123 if self.active_round != Some(prepared.id) || self.security_preflight {
16124 return crate::AppliedSyncRound::Complete {
16125 outcome: SyncOutcome::Failed {
16126 error_code: "client.round_cancelled".into(),
16127 message: "sync round belongs to a released client context".into(),
16128 details: None,
16129 },
16130 controls: Vec::new(),
16131 more: false,
16132 bootstrap_advanced: false,
16133 };
16134 }
16135 self.storage_failure.borrow_mut().take();
16136 let (response, mut downloads, transport_failed) = match completed.exchange {
16137 crate::round::ExchangeResult::Upload { id, result } => {
16138 let result = result.and_then(|()| {
16139 self.conn
16140 .execute(
16141 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
16142 rusqlite::params![id],
16143 )
16144 .map(|_| ())
16145 .map_err(|error| {
16146 TransportError::new(
16147 "client.failed",
16148 Self::sqlite_failure(&self.storage_failure, error),
16149 )
16150 })
16151 });
16152 match result {
16153 Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
16154 Err(TransportError {
16155 code,
16156 message,
16157 details,
16158 }) => {
16159 self.active_round = None;
16160 if Self::retryable_failure_code(&code) {
16161 self.schedule_background_retry();
16162 }
16163 let outcome = self.finish_sync_round(
16164 prepared.started_at_ms,
16165 SyncOutcome::Failed {
16166 error_code: code,
16167 message,
16168 details,
16169 },
16170 );
16171 return crate::AppliedSyncRound::Complete {
16172 outcome,
16173 controls: Vec::new(),
16174 more: false,
16175 bootstrap_advanced: false,
16176 };
16177 }
16178 }
16179 }
16180 crate::round::ExchangeResult::Reply {
16181 response,
16182 downloads,
16183 transport_failed,
16184 } => (response, downloads, transport_failed),
16185 };
16186 self.active_round = None;
16187 let before = self
16188 .subs
16189 .iter()
16190 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
16191 .collect::<Vec<_>>();
16192 let outcome = (|| {
16193 let response = match response {
16194 Ok(response) => response,
16195 Err(TransportError {
16196 code,
16197 message,
16198 details,
16199 }) => {
16200 if transport_failed {
16201 if prepared.realtime {
16202 self.set_realtime_state(RealtimeState::Lost, Some(&code));
16203 }
16204 self.record_lease_error(&code);
16205 }
16206 if Self::retryable_failure_code(&code) {
16207 let delay = self.schedule_background_retry();
16208 if prepared.realtime && transport_failed {
16209 self.realtime_retry_delay_ms = Some(delay);
16210 }
16211 }
16212 return SyncOutcome::Failed {
16213 error_code: code,
16214 message,
16215 details,
16216 };
16217 }
16218 };
16219 let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
16220 if let SyncOutcome::Ok(report) = &mut outcome {
16221 report.deferred_commits = prepared.meta.deferred_commits;
16222 }
16223 match &outcome {
16224 SyncOutcome::Ok(_) => self.reset_background_retry(),
16225 SyncOutcome::Failed { error_code, .. }
16226 if Self::retryable_failure_code(error_code) =>
16227 {
16228 self.schedule_background_retry();
16229 }
16230 _ => {}
16231 }
16232 if prepared.meta.deferred_commits > 0 {
16233 self.set_sync_needed(true, true);
16234 }
16235 self.previous_version_lifetime_check();
16236 outcome
16237 })();
16238 let more = self.transport_enabled
16239 && matches!(&outcome, SyncOutcome::Ok(report)
16240 if report.schema_floor.is_none()
16241 && (!report.bootstrapping.is_empty()
16242 || report.commits_applied > 0
16243 || report.segment_rows_applied > 0
16244 || !report.resets.is_empty()
16245 || report.deferred_commits > 0
16246 || self.sync_needed));
16247 let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
16248 && before
16249 != self
16250 .subs
16251 .iter()
16252 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
16253 .collect::<Vec<_>>();
16254 crate::AppliedSyncRound::Complete {
16255 outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
16256 controls: if self.transport_enabled {
16257 downloads.controls
16258 } else {
16259 Vec::new()
16260 },
16261 more,
16262 bootstrap_advanced,
16263 }
16264 }
16265
16266 pub fn sync_until_idle(
16267 &mut self,
16268 transport: &mut dyn Transport,
16269 max_rounds: Option<u32>,
16270 ) -> SyncOutcome {
16271 if max_rounds == Some(0) {
16274 return SyncOutcome::Failed {
16275 details: None,
16276 error_code: "sync.invalid_request".into(),
16277 message: "maxRounds must be a positive integer".into(),
16278 };
16279 }
16280 let rounds = max_rounds.unwrap_or(20);
16281 let mut aggregate = SyncReport::default();
16282 let mut spent = 0;
16283 while spent < rounds {
16284 spent += 1;
16285 let before = self
16286 .subs
16287 .iter()
16288 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
16289 .collect::<Vec<_>>();
16290 match self.sync(transport) {
16291 SyncOutcome::Failed {
16292 error_code,
16293 message,
16294 details,
16295 } => {
16296 return SyncOutcome::Failed {
16297 details,
16298 error_code,
16299 message,
16300 };
16301 }
16302 outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
16303 SyncOutcome::BudgetExhausted(_) => {
16304 unreachable!("sync() never exhausts a round budget")
16305 }
16306 SyncOutcome::Ok(report) => {
16307 if max_rounds.is_none()
16308 && report.segment_rows_applied > 0
16309 && !report.bootstrapping.is_empty()
16310 && before
16311 != self
16312 .subs
16313 .iter()
16314 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
16315 .collect::<Vec<_>>()
16316 {
16317 spent = 0;
16318 }
16319 let stop = !self.transport_enabled() || report.schema_floor.is_some();
16322 aggregate.merge(&report);
16323 if stop {
16324 return SyncOutcome::Ok(aggregate);
16325 }
16326 let more = !report.bootstrapping.is_empty()
16332 || report.commits_applied > 0
16333 || report.segment_rows_applied > 0
16334 || !report.resets.is_empty()
16335 || report.deferred_commits > 0
16336 || self.sync_needed;
16337 if !more {
16338 return SyncOutcome::Ok(aggregate);
16339 }
16340 }
16341 }
16342 }
16343 SyncOutcome::BudgetExhausted(aggregate)
16346 }
16347
16348 fn process_response(
16349 &mut self,
16350 transport: &mut dyn Transport,
16351 response: Message,
16352 meta: &RequestMeta,
16353 ) -> SyncOutcome {
16354 #[cfg(feature = "bench-internals")]
16355 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
16356 let mut report = SyncReport {
16357 pushed: meta.pushed_ids.len() as u32,
16358 ..SyncReport::default()
16359 };
16360 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
16361 HashMap::new();
16362 let pushed_ids = meta
16363 .pushed_ids
16364 .iter()
16365 .map(String::as_str)
16366 .collect::<HashSet<_>>();
16367 let mut last_final_push_result_id: Option<String> = None;
16368 for frame in &response.frames {
16369 match frame {
16370 Frame::PushResultDetails {
16371 client_commit_id,
16372 entries,
16373 } => {
16374 let details = rejection_details_by_commit
16375 .entry(client_commit_id.clone())
16376 .or_default();
16377 for entry in entries {
16378 let parsed = match RejectionDetails::parse(&entry.details.0) {
16379 Ok(value) => value,
16380 Err(message) => {
16381 return SyncOutcome::Failed {
16382 details: None,
16383 error_code: "sync.invalid_request".to_owned(),
16384 message,
16385 };
16386 }
16387 };
16388 details.insert(entry.op_index, parsed);
16389 }
16390 }
16391 Frame::PushResult {
16392 client_commit_id,
16393 status,
16394 results,
16395 ..
16396 } if pushed_ids.contains(client_commit_id.as_str())
16397 && Self::push_result_is_final(*status, results) =>
16398 {
16399 last_final_push_result_id = Some(client_commit_id.clone());
16400 }
16401 _ => {}
16402 }
16403 }
16404 let mut frames = response.frames.into_iter().peekable();
16405 if response.wire_version < 2 {
16406 return SyncOutcome::Failed {
16407 details: None,
16408 error_code: "client.invalid_host_response".to_owned(),
16409 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
16410 };
16411 }
16412 match frames.next() {
16413 Some(Frame::RespHeader {
16414 required_schema_version,
16415 latest_schema_version,
16416 log_epoch,
16417 reset_required,
16418 }) => {
16419 let Some(log_epoch) = log_epoch else {
16420 return SyncOutcome::Failed {
16421 details: None,
16422 error_code: "client.invalid_host_response".to_owned(),
16423 message: "response header omits logEpoch".to_owned(),
16424 };
16425 };
16426 let Some(reset_required) = reset_required else {
16427 return SyncOutcome::Failed {
16428 details: None,
16429 error_code: "client.invalid_host_response".to_owned(),
16430 message: "response header omits resetRequired".to_owned(),
16431 };
16432 };
16433 if let Some(required) = required_schema_version {
16434 let floor = SchemaFloor {
16437 required_schema_version: Some(required),
16438 latest_schema_version,
16439 };
16440 self.set_schema_floor(Some(floor.clone()));
16441 report.schema_floor = Some(floor);
16442 return SyncOutcome::Ok(report);
16443 }
16444 if reset_required {
16445 if frames.next().is_some() {
16446 return SyncOutcome::Failed {
16447 details: None,
16448 error_code: "client.invalid_host_response".to_owned(),
16449 message: "log-epoch reset response contains body frames".to_owned(),
16450 };
16451 }
16452 match self.run_log_epoch_reset(&log_epoch) {
16453 Ok(resets) => {
16454 report.resets = resets;
16455 return SyncOutcome::Ok(report);
16456 }
16457 Err(message) => {
16458 return SyncOutcome::Failed {
16459 details: None,
16460 error_code: "sync.local_corrupt".to_owned(),
16461 message,
16462 };
16463 }
16464 }
16465 }
16466 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
16467 return SyncOutcome::Failed {
16468 details: None,
16469 error_code: "client.invalid_host_response".to_owned(),
16470 message: "server changed logEpoch without requiring a reset".to_owned(),
16471 };
16472 }
16473 }
16474 _ => {
16475 return SyncOutcome::Failed {
16476 details: None,
16477 error_code: "sync.invalid_request".to_owned(),
16478 message: "response does not start with RESP_HEADER".to_owned(),
16479 };
16480 }
16481 }
16482
16483 if let Err(message) = self.begin_coalesced_apply() {
16484 return SyncOutcome::Failed {
16485 details: None,
16486 error_code: "storage.failed".into(),
16487 message,
16488 };
16489 }
16490 let mut failure: Option<(String, String)> = None;
16491 while let Some(frame) = frames.next() {
16492 match frame {
16493 frame @ Frame::PushResult { .. } => {
16494 let successful = matches!(
16495 &frame,
16496 Frame::PushResult {
16497 status: PushStatus::Applied | PushStatus::Cached,
16498 ..
16499 }
16500 );
16501 let mut results = vec![frame];
16502 if successful {
16503 while matches!(
16504 frames.peek(),
16505 Some(Frame::PushResult {
16506 status: PushStatus::Applied | PushStatus::Cached,
16507 ..
16508 })
16509 ) {
16510 if let Some(next) = frames.next() {
16511 results.push(next);
16512 }
16513 }
16514 }
16515 if self
16516 .handle_push_results(
16517 &results,
16518 &pushed_ids,
16519 &rejection_details_by_commit,
16520 &mut report,
16521 last_final_push_result_id.as_deref(),
16522 )
16523 .is_err()
16524 {
16525 failure = Some((
16526 "client.outcome_persistence_failed".to_owned(),
16527 "local commit outcome could not be persisted".to_owned(),
16528 ));
16529 break;
16530 }
16531 }
16532 Frame::PushResultDetails { .. } => {}
16533 Frame::SubStart {
16534 id,
16535 status,
16536 reason_code,
16537 effective_scopes,
16538 bootstrap: _,
16539 } => {
16540 let mut body = Vec::new();
16541 let mut sub_end: Option<(i64, Option<String>)> = None;
16542 for inner in frames.by_ref() {
16543 match inner {
16544 Frame::SubEnd {
16545 next_cursor,
16546 bootstrap_state,
16547 } => {
16548 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
16549 break;
16550 }
16551 Frame::Unknown { .. } => {}
16552 other => body.push(other),
16553 }
16554 }
16555 if let Err(SectionError::Abort(code, message)) = self.process_section(
16556 transport,
16557 &id,
16558 status,
16559 &reason_code,
16560 effective_scopes,
16561 body,
16562 sub_end,
16563 meta,
16564 &mut report,
16565 ) {
16566 failure = Some((code, message));
16567 break;
16568 }
16569 }
16570 Frame::Lease {
16571 lease_id,
16572 expires_at_ms,
16573 } => {
16574 self.set_lease_state(Some(LeaseState {
16577 lease_id: Some(lease_id),
16578 expires_at_ms: Some(expires_at_ms),
16579 error_code: None,
16580 }));
16581 }
16582 Frame::Error { code, message, .. } => {
16583 failure = Some((code, message));
16586 break;
16587 }
16588 Frame::Unknown { .. } => {}
16589 _ => {
16590 failure = Some((
16591 "sync.invalid_request".to_owned(),
16592 "unexpected frame in response".to_owned(),
16593 ));
16594 break;
16595 }
16596 }
16597 }
16598
16599 let applied = match failure {
16600 Some(failure) => Err(failure),
16601 None => self
16606 .rebuild_overlay_if_dirty()
16607 .and_then(|()| self.drain_pending_evictions())
16608 .map_err(|message| ("storage.failed".to_owned(), message)),
16609 };
16610 if let Err(message) = self.finish_coalesced_apply() {
16613 return SyncOutcome::Failed {
16614 details: None,
16615 error_code: "storage.failed".into(),
16616 message,
16617 };
16618 }
16619 if let Err((error_code, message)) = applied {
16620 return SyncOutcome::Failed {
16621 details: None,
16622 error_code,
16623 message,
16624 };
16625 }
16626 self.ack_after_pull(transport);
16627 if self.upgrading && report.bootstrapping.is_empty() {
16630 self.set_upgrading(false);
16631 }
16632 SyncOutcome::Ok(report)
16633 }
16634
16635 fn handle_push_results(
16638 &mut self,
16639 frames: &[Frame],
16640 pushed_ids: &HashSet<&str>,
16641 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
16642 report: &mut SyncReport,
16643 last_final_push_result_id: Option<&str>,
16644 ) -> Result<(), String> {
16645 self.begin_observation("syncular_push_results")?;
16646 let previous_failed = self.failed_commits.clone();
16647 let previous_sync_needed = self.sync_needed;
16648 let conflict_count = self.conflicts.len();
16649 let rejection_count = self.rejections.len();
16650 let applied_count = report.applied.len();
16651 let rejected_count = report.rejected.len();
16652 let retryable_count = report.retryable.len();
16653 let was_dirty = self.overlay_dirty.snapshot();
16654 let mut removed = Vec::new();
16655 let mut batch = ChangeAccumulator::default();
16656 let persisted = (|| {
16657 for frame in frames {
16658 let Frame::PushResult {
16659 client_commit_id,
16660 status,
16661 results,
16662 commit_seq,
16663 } = frame
16664 else {
16665 return Err("unexpected acknowledgement frame".to_owned());
16666 };
16667 if !pushed_ids.contains(client_commit_id.as_str()) {
16668 continue;
16669 }
16670 let Some(index) = self
16671 .outbox
16672 .iter()
16673 .position(|commit| commit.client_commit_id == *client_commit_id)
16674 else {
16675 continue;
16676 };
16677 let status = *status;
16678 let rejection_details = rejection_details_by_commit.get(client_commit_id);
16679 let operations = self.outbox[index].ops.clone();
16680 match status {
16681 PushStatus::Applied | PushStatus::Cached => {
16682 let sequence =
16685 commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
16686 let affected: HashSet<_> = operations
16687 .iter()
16688 .map(|op| (op.table.as_str(), op.row_id.as_str()))
16689 .collect();
16690 let prior = self.acknowledged_operations(Some(&affected), None)?;
16691 for (op_index, operation) in operations.iter().enumerate() {
16692 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))
16693 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16694 if delivered {
16695 continue;
16696 }
16697 let table = self
16698 .schema
16699 .table(&operation.table)
16700 .ok_or_else(|| "sync.unknown_table".to_owned())?;
16701 let mut intended: Map<String, Value> = self
16702 .stored_row(table, &operation.row_id, true)?
16703 .map(|(row, _)| {
16704 table
16705 .columns
16706 .iter()
16707 .zip(&row)
16708 .map(|(column, value)| {
16709 (column.name.clone(), column_value_to_json(value))
16710 })
16711 .collect()
16712 })
16713 .unwrap_or_default();
16714 let mut scope_image = intended.clone();
16715 for op in prior
16716 .iter()
16717 .chain(
16718 self.failed_commits
16719 .iter()
16720 .flat_map(|(commit, _)| &commit.ops),
16721 )
16722 .chain(
16723 self.outbox
16724 .iter()
16725 .take(index)
16726 .flat_map(|commit| &commit.ops),
16727 )
16728 .chain(operations.iter().take(op_index + 1))
16729 {
16730 if op.table != operation.table || op.row_id != operation.row_id {
16731 continue;
16732 }
16733 if let Some(values) = &op.values {
16734 intended.extend(values.clone());
16735 } else {
16736 scope_image = intended.clone();
16737 intended.clear();
16738 }
16739 }
16740 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)",
16741 rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
16742 serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
16743 serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
16744 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16745 }
16746 let journal_results = results
16747 .iter()
16748 .map(|result| {
16749 let op_index = match result {
16750 OpResult::Applied { op_index }
16751 | OpResult::Conflict { op_index, .. }
16752 | OpResult::Error { op_index, .. } => *op_index,
16753 };
16754 CommitOperationOutcome::Applied { op_index }
16755 })
16756 .collect::<Vec<_>>();
16757 let outcome_status = if status == PushStatus::Applied {
16758 CommitOutcomeStatus::Applied
16759 } else {
16760 CommitOutcomeStatus::Cached
16761 };
16762 let persisted = self
16763 .persist_commit_outcome(
16764 client_commit_id,
16765 outcome_status,
16766 &journal_results,
16767 None,
16768 )
16769 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
16770 persisted?;
16771 for operation in &operations {
16772 self.overlay_dirty.table(&operation.table);
16773 }
16774 self.sync_needed = true;
16775 batch.status = true;
16776 batch.outcomes = true;
16777 }
16778 PushStatus::Rejected => {
16779 if results.iter().any(|result| {
16780 matches!(
16781 result,
16782 OpResult::Error {
16783 code,
16784 retryable: true,
16785 ..
16786 } if code == "sync.idempotency_cache_miss"
16787 )
16788 }) {
16789 report.retryable.push(client_commit_id.to_owned());
16792 continue;
16793 }
16794
16795 let mut journal_results = Vec::with_capacity(results.len());
16796 let mut conflicts = Vec::new();
16797 let mut rejections = Vec::new();
16798 for result in results {
16799 match result {
16800 OpResult::Applied { op_index } => {
16801 journal_results.push(CommitOperationOutcome::Applied {
16802 op_index: *op_index,
16803 });
16804 }
16805 OpResult::Conflict {
16806 op_index,
16807 code,
16808 message,
16809 server_version,
16810 server_row,
16811 conflict_columns: conflict_columns_bitmap,
16812 } => {
16813 let operation = operations
16814 .get(*op_index as usize)
16815 .map(CommitOperation::from);
16816 let (table, row_id) = operation
16817 .as_ref()
16818 .map(|op| (op.table.clone(), op.row_id.clone()))
16819 .unwrap_or_default();
16820 let server_row_json = self
16821 .schema
16822 .table(&table)
16823 .and_then(|t| {
16824 decode_row_bytes(t, server_row, &self.encryption)
16825 .ok()
16826 .map(|row| (t, row))
16827 })
16828 .map(|(t, row)| {
16829 let mut map = Map::new();
16830 for (i, column) in t.columns.iter().enumerate() {
16831 map.insert(
16832 column.name.clone(),
16833 column_value_to_json(
16834 row.get(i).unwrap_or(&None),
16835 ),
16836 );
16837 }
16838 map
16839 })
16840 .unwrap_or_default();
16841 let conflict_columns = self
16844 .schema
16845 .table(&table)
16846 .map(|t| {
16847 t.columns
16848 .iter()
16849 .enumerate()
16850 .filter(|(index, _)| {
16851 conflict_columns_bitmap
16852 .get(index / 8)
16853 .is_some_and(|byte| {
16854 byte & (1 << (index % 8)) != 0
16855 })
16856 })
16857 .map(|(_, column)| column.name.clone())
16858 .collect::<Vec<_>>()
16859 })
16860 .unwrap_or_default();
16861 let conflict = ConflictRecord {
16862 client_commit_id: client_commit_id.to_owned(),
16863 op_index: *op_index,
16864 table,
16865 row_id,
16866 code: code.clone(),
16867 message: message.clone(),
16868 server_version: *server_version,
16869 server_row: server_row_json,
16870 conflict_columns,
16871 operation,
16872 };
16873 journal_results.push(CommitOperationOutcome::Conflict {
16874 conflict: conflict.clone(),
16875 });
16876 conflicts.push(conflict);
16877 }
16878 OpResult::Error {
16879 op_index,
16880 code,
16881 message,
16882 retryable,
16883 } => {
16884 let rejection = RejectionRecord {
16885 client_commit_id: client_commit_id.to_owned(),
16886 op_index: *op_index,
16887 code: code.clone(),
16888 message: message.clone(),
16889 retryable: *retryable,
16890 details: rejection_details
16891 .and_then(|details| details.get(op_index))
16892 .cloned(),
16893 operation: operations
16894 .get(*op_index as usize)
16895 .map(CommitOperation::from),
16896 };
16897 journal_results.push(CommitOperationOutcome::Error {
16898 rejection: rejection.clone(),
16899 });
16900 rejections.push(rejection);
16901 }
16902 }
16903 }
16904 if self.retain_failed_commits {
16905 self.retain_failed_commit(client_commit_id, &operations)?;
16906 }
16907 let outcome_status = if conflicts.is_empty() {
16908 CommitOutcomeStatus::Rejected
16909 } else {
16910 CommitOutcomeStatus::Conflict
16911 };
16912 let persisted = self
16913 .persist_commit_outcome(
16914 client_commit_id,
16915 outcome_status,
16916 &journal_results,
16917 Some(&operations),
16918 )
16919 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
16920 persisted?;
16921 batch.conflicts = !conflicts.is_empty();
16922 batch.rejections = !rejections.is_empty();
16923 batch.status = true;
16924 batch.outcomes = true;
16925 self.conflicts.extend(conflicts);
16926 self.rejections.extend(rejections);
16927 for operation in &operations {
16928 self.overlay_dirty.table(&operation.table);
16929 }
16930 }
16931 }
16932 removed.push((index, self.outbox.remove(index)));
16933 if batch.status {
16934 for operation in &operations {
16935 if !self.record_row_scopes(
16936 &mut batch,
16937 &operation.table,
16938 &operation.row_id,
16939 false,
16940 ) {
16941 batch.table(&operation.table);
16942 }
16943 }
16944 }
16945 match status {
16946 PushStatus::Applied | PushStatus::Cached => {
16947 report.applied.push(client_commit_id.to_owned())
16948 }
16949 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
16950 }
16951 }
16952 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)) {
16953 self.prune_commit_outcomes()?;
16954 }
16955 self.rebuild_overlay_if_dirty()?;
16956 self.finish_observation("syncular_push_results", batch)
16957 })();
16958 if let Err(error) = persisted {
16959 self.rollback_observation("syncular_push_results");
16960 self.failed_commits = previous_failed;
16961 self.sync_needed = previous_sync_needed;
16962 for (index, commit) in removed.into_iter().rev() {
16963 self.outbox.insert(index, commit);
16964 }
16965 self.conflicts.truncate(conflict_count);
16966 self.rejections.truncate(rejection_count);
16967 self.overlay_dirty.restore(was_dirty.clone());
16968 report.applied.truncate(applied_count);
16969 report.rejected.truncate(rejected_count);
16970 report.retryable.truncate(retryable_count);
16971 return Err(error);
16972 }
16973 if !previous_sync_needed && self.sync_needed {
16974 self.sync_intent_queue.push_back(SyncIntent::Interactive);
16975 }
16976 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
16977 Ok(())
16978 }
16979
16980 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
16981 status != PushStatus::Rejected
16982 || !results.iter().any(|result| {
16983 matches!(
16984 result,
16985 OpResult::Error {
16986 code,
16987 retryable: true,
16988 ..
16989 } if code == "sync.idempotency_cache_miss"
16990 )
16991 })
16992 }
16993
16994 #[allow(clippy::too_many_arguments)]
16997 fn process_section(
16998 &mut self,
16999 transport: &mut dyn Transport,
17000 id: &str,
17001 status: SubStatus,
17002 reason_code: &str,
17003 effective_scopes: Vec<(String, Vec<String>)>,
17004 body: Vec<Frame>,
17005 sub_end: Option<(i64, Option<String>)>,
17006 meta: &RequestMeta,
17007 report: &mut SyncReport,
17008 ) -> Result<(), SectionError> {
17009 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
17010 return Ok(()); };
17012 if status != SubStatus::Active && sub_end.is_none() {
17013 return Err(SectionError::Abort(
17014 "sync.invalid_request".into(),
17015 "subscription section without SUB_END".into(),
17016 ));
17017 }
17018 match status {
17019 SubStatus::Revoked => {
17020 self.begin_observation("syncular_revocation")
17021 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
17022 let previous = self.subs[sub_index].clone();
17023 let was_dirty = self.overlay_dirty.snapshot();
17024 let previous_outbox = self.outbox.clone();
17025 let previous_failed = self.failed_commits.clone();
17026 let previous_rejections = self.rejections.clone();
17027 let previous_conflicts = self.conflicts.clone();
17028 let revoked_count = report.revoked.len();
17029 let failed_count = report.failed.len();
17030 let result = (|| {
17031 let mut batch = ChangeAccumulator::default();
17032 let registered = self.window_unit_by_sub_id(id);
17033 let (table, effective) = {
17035 let sub = &self.subs[sub_index];
17036 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17037 };
17038 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
17039 SectionError::Abort(
17040 "sync.unknown_table".to_owned(),
17041 "revoked scope references an unknown table".to_owned(),
17042 )
17043 })?;
17044 let dropped = self
17045 .drop_failed_commits(|name, values| {
17046 name == table
17047 && !effective.is_empty()
17048 && effective.iter().all(|(variable, allowed)| {
17049 scoped_table
17050 .scope_column(variable)
17051 .and_then(|column| values.get(column))
17052 .and_then(Value::as_str)
17053 .is_some_and(|value| {
17054 allowed.iter().any(|allowed| allowed == value)
17055 })
17056 })
17057 })
17058 .map_err(|message| {
17059 SectionError::Abort("storage.failed".to_owned(), message)
17060 })?;
17061 for commit in dropped {
17062 batch.conflicts = true;
17063 batch.rejections = true;
17064 batch.outcomes = true;
17065 for operation in commit.ops {
17066 batch.table(&operation.table);
17067 }
17068 }
17069 let purged = self.purge_scope_rows(&table, &effective);
17070 match purged {
17071 Ok(()) => {
17072 self.record_scope_map(&mut batch, &table, &effective);
17073 let sub = &mut self.subs[sub_index];
17074 sub.state = SubState::Revoked;
17075 sub.reason_code = Some(if reason_code.is_empty() {
17076 "sync.scope_revoked".to_owned()
17077 } else {
17078 reason_code.to_owned()
17079 });
17080 report.revoked.push(id.to_owned());
17081 let doomed_effective = effective;
17082 let sub_table = table;
17083 self.persist_sub(&self.subs[sub_index].clone())
17084 .map_err(|message| {
17085 SectionError::Abort("storage.failed".into(), message)
17086 })?;
17087 let dropped = self
17088 .drop_doomed_outbox(&sub_table, &doomed_effective)
17089 .map_err(|message| {
17090 SectionError::Abort("storage.failed".to_owned(), message)
17091 })?;
17092 if dropped {
17093 batch.status = true;
17094 batch.rejections = true;
17095 batch.outcomes = true;
17096 }
17097 self.delete_unreferenced_cached_blobs().map_err(|message| {
17100 SectionError::Abort("storage.failed".into(), message)
17101 })?;
17102 }
17103 Err(()) => {
17104 let sub = &mut self.subs[sub_index];
17107 sub.state = SubState::Failed;
17108 sub.reason_code = Some("sync.scope_revoked".to_owned());
17109 report.failed.push(id.to_owned());
17110 self.persist_sub(&self.subs[sub_index].clone())
17111 .map_err(|message| {
17112 SectionError::Abort("storage.failed".into(), message)
17113 })?;
17114 }
17115 }
17116 if let Some((base_key, unit)) = registered {
17117 batch.window(&base_key, &self.subs[sub_index].table, &unit);
17118 }
17119 self.rebuild_overlay_if_dirty()
17120 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17121 self.finish_observation("syncular_revocation", batch)
17122 .map_err(|message| {
17123 SectionError::Abort("storage.failed".to_owned(), message)
17124 })?;
17125 Ok(())
17126 })();
17127 if result.is_err() {
17128 self.rollback_observation("syncular_revocation");
17129 self.subs[sub_index] = previous;
17130 self.overlay_dirty.restore(was_dirty.clone());
17131 self.outbox = previous_outbox;
17132 self.failed_commits = previous_failed;
17133 self.rejections = previous_rejections;
17134 self.conflicts = previous_conflicts;
17135 report.revoked.truncate(revoked_count);
17136 report.failed.truncate(failed_count);
17137 }
17138 result
17139 }
17140 SubStatus::Reset => {
17141 self.begin_observation("syncular_reset")
17142 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
17143 let previous = self.subs[sub_index].clone();
17144 let was_dirty = self.overlay_dirty.snapshot();
17145 let reset_count = report.resets.len();
17146 let result = (|| {
17147 let mut batch = ChangeAccumulator::default();
17148 let registered = self.window_unit_by_sub_id(id);
17149 let sub = &mut self.subs[sub_index];
17152 sub.cursor = -1;
17153 sub.bootstrap_state = None;
17154 sub.reason_code = Some(if reason_code.is_empty() {
17155 "sync.cursor_expired".to_owned()
17156 } else {
17157 reason_code.to_owned()
17158 });
17159 report.resets.push(id.to_owned());
17160 self.persist_sub(&self.subs[sub_index].clone())
17161 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17162 if let Some((base_key, unit)) = registered {
17163 batch.window(&base_key, &self.subs[sub_index].table, &unit);
17164 }
17165 self.finish_observation("syncular_reset", batch)
17166 .map_err(|message| {
17167 SectionError::Abort("storage.failed".to_owned(), message)
17168 })?;
17169 Ok(())
17170 })();
17171 if result.is_err() {
17172 self.rollback_observation("syncular_reset");
17173 self.subs[sub_index] = previous;
17174 self.overlay_dirty.restore(was_dirty.clone());
17175 report.resets.truncate(reset_count);
17176 }
17177 result
17178 }
17179 SubStatus::Active => {
17180 let fresh = meta
17181 .fresh
17182 .iter()
17183 .find(|(fid, _)| fid == id)
17184 .map(|(_, fresh)| *fresh)
17185 .unwrap_or(false);
17186 let previous = self.subs[sub_index].clone();
17187 self.subs[sub_index].effective = Some(effective_scopes);
17188 let outcome = self
17189 .apply_section_body(transport, sub_index, body, fresh, meta, report)
17190 .and_then(|()| {
17191 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
17192 SectionError::Abort(
17193 "sync.invalid_request".into(),
17194 "subscription section without SUB_END".into(),
17195 )
17196 })?;
17197 self.apply_sub_end(sub_index, cursor, bootstrap_state)
17198 .map_err(|message| {
17199 SectionError::Abort("storage.failed".into(), message)
17200 })
17201 });
17202 match outcome {
17203 Ok(()) => {
17204 if self.subs[sub_index].bootstrap_state.is_some() {
17205 report.bootstrapping.push(id.to_owned());
17206 }
17207 Ok(())
17208 }
17209 Err(SectionError::FailClosed) => {
17210 self.subs[sub_index] = previous.clone();
17211 self.begin_observation("syncular_section_failure")
17212 .map_err(|message| {
17213 SectionError::Abort("storage.failed".into(), message)
17214 })?;
17215 let persisted = (|| {
17216 let mut batch = ChangeAccumulator::default();
17217 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
17218 batch.window(&base_key, &self.subs[sub_index].table, &unit);
17219 }
17220 self.subs[sub_index].state = SubState::Failed;
17221 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
17222 self.persist_sub(&self.subs[sub_index])?;
17223 self.finish_observation("syncular_section_failure", batch)
17224 })();
17225 if let Err(message) = persisted {
17226 self.rollback_observation("syncular_section_failure");
17227 self.subs[sub_index] = previous;
17228 return Err(SectionError::Abort("storage.failed".into(), message));
17229 }
17230 report.failed.push(id.to_owned());
17231 Ok(())
17232 }
17233 Err(error) => {
17234 self.subs[sub_index] = previous;
17237 Err(error)
17238 }
17239 }
17240 }
17241 }
17242 }
17243
17244 fn apply_sub_end(
17245 &mut self,
17246 sub_index: usize,
17247 cursor: i64,
17248 bootstrap_state: Option<String>,
17249 ) -> Result<(), String> {
17250 #[cfg(feature = "bench-internals")]
17251 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
17252 let previous = self.subs[sub_index].clone();
17253 self.begin_observation("syncular_sub_end")?;
17254 let persisted = (|| {
17255 let mut batch = ChangeAccumulator::default();
17256 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
17257 && cursor >= 0
17258 && bootstrap_state.is_none();
17259 if completed {
17260 let table = self
17261 .schema
17262 .table(&previous.table)
17263 .ok_or_else(|| "sync.unknown_table".to_owned())?;
17264 let effective = previous.effective.as_deref().unwrap_or(&[]);
17265 let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
17266 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17267 let rows = statement
17268 .query_map(rusqlite::params![previous.table, cursor], |row| {
17269 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
17270 })
17271 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17272 let mut covered = HashSet::new();
17273 for row in rows {
17274 let (id, intent) =
17275 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17276 let values: Map<String, Value> = serde_json::from_str(&intent)
17277 .map_err(|_| "sync.local_corrupt".to_owned())?;
17278 if !effective.is_empty()
17279 && effective.iter().all(|(variable, allowed)| {
17280 table
17281 .scope_column(variable)
17282 .and_then(|column| values.get(column))
17283 .is_some_and(|value| {
17284 allowed.contains(
17285 &value
17286 .as_str()
17287 .map_or_else(|| value.to_string(), str::to_owned),
17288 )
17289 })
17290 })
17291 {
17292 covered.insert(id);
17293 }
17294 }
17295 drop(statement);
17296 for id in covered {
17297 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
17298 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17299 self.overlay_dirty.table(&previous.table);
17300 batch.table(&previous.table);
17301 }
17302 self.rebuild_overlay_if_dirty()?;
17303 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
17304 batch.window(&base_key, &previous.table, &unit);
17305 }
17306 }
17307 let sub = &mut self.subs[sub_index];
17308 sub.cursor = cursor;
17309 sub.bootstrap_state = bootstrap_state;
17310 sub.reason_code = None;
17313 sub.synced_once = true;
17314 self.persist_sub(&self.subs[sub_index])?;
17315 self.finish_observation("syncular_sub_end", batch)
17316 })();
17317 if persisted.is_err() {
17318 self.rollback_observation("syncular_sub_end");
17319 self.subs[sub_index] = previous;
17320 }
17321 persisted
17322 }
17323
17324 fn apply_section_body(
17325 &mut self,
17326 transport: &mut dyn Transport,
17327 sub_index: usize,
17328 body: Vec<Frame>,
17329 fresh: bool,
17330 meta: &RequestMeta,
17331 report: &mut SyncReport,
17332 ) -> Result<(), SectionError> {
17333 let mut saw_segment = false;
17334 for frame in body {
17335 match frame {
17336 Frame::Commit {
17337 tables,
17338 changes,
17339 commit_seq,
17340 ..
17341 } => {
17342 self.apply_commit_frame(&tables, &changes, Some(commit_seq))
17343 .map_err(|(c, m)| SectionError::Abort(c, m))?;
17344 report.commits_applied += 1;
17345 }
17346 Frame::SegmentInline { payload } => {
17347 let segment = decode_rows_segment(&payload)
17348 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
17349 self.progress.update(|p| {
17350 p.phase = ProgressPhase::Import;
17351 p.subscription_id = Some(self.subs[sub_index].id.clone());
17352 p.table = Some(self.subs[sub_index].table.clone());
17353 p.segment_id = None;
17354 p.bytes_received = payload.len() as u64;
17355 p.bytes_total = Some(payload.len() as u64);
17356 p.rows_processed = 0;
17357 p.rows_total =
17358 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
17359 });
17360 let first = !saw_segment;
17361 saw_segment = true;
17362 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
17363 report.segment_rows_applied += applied;
17364 }
17365 Frame::SegmentRef {
17366 segment_id,
17367 byte_length,
17368 media_type,
17369 table,
17370 row_count,
17371 as_of_commit_seq,
17372 scope_digest,
17373 row_cursor,
17374 next_row_cursor,
17375 url,
17376 url_expires_at_ms,
17377 ..
17378 } => {
17379 let advertised = match media_type {
17382 MediaType::Rows => {
17383 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
17384 || meta.accept & ACCEPT_INLINE_ROWS != 0
17385 }
17386 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
17387 };
17388 if !advertised {
17389 return Err(SectionError::Abort(
17390 "sync.invalid_request".to_owned(),
17391 format!(
17392 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
17393 media_type.name()
17394 ),
17395 ));
17396 }
17397 self.progress.update(|p| {
17398 p.phase = ProgressPhase::Download;
17399 p.subscription_id = Some(self.subs[sub_index].id.clone());
17400 p.table = Some(table.clone());
17401 p.segment_id = Some(segment_id.clone());
17402 p.bytes_received = 0;
17403 p.bytes_total = u64::try_from(byte_length).ok();
17404 p.rows_processed = 0;
17405 p.rows_total = u64::try_from(row_count).ok();
17406 });
17407 let observer = self.progress.clone();
17408 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
17409 let bytes = if let Some(url) = url {
17410 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
17415 return Err(SectionError::Abort(
17416 "sync.invalid_request".to_owned(),
17417 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
17418 .to_owned(),
17419 ));
17420 }
17421 if url_expires_at_ms.is_some_and(|exp| {
17423 exp <= transport
17424 .segment_fetch_started_at(&url)
17425 .unwrap_or_else(|| self.clock_now_ms())
17426 }) {
17427 return Err(SectionError::Abort(
17428 "sync.segment_expired".to_owned(),
17429 format!(
17430 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
17431 ),
17432 ));
17433 }
17434 transport
17435 .fetch_url(&url, &mut on_progress)
17436 .map_err(|e| SectionError::Abort(e.code, e.message))?
17437 } else {
17438 let requested_scopes_json =
17439 canonical_scope_json(&self.subs[sub_index].requested);
17440 transport
17441 .download_segment(
17442 &SegmentRequest {
17443 segment_id: segment_id.clone(),
17444 table,
17445 requested_scopes_json,
17446 },
17447 &mut on_progress,
17448 )
17449 .map_err(|e| SectionError::Abort(e.code, e.message))?
17450 };
17451 on_progress(bytes.len() as u64);
17453 let digest = Sha256::digest(&bytes);
17454 let expected = segment_id
17455 .strip_prefix("sha256:")
17456 .unwrap_or(segment_id.as_str());
17457 if bytes_to_hex(&digest) != expected {
17458 self.schedule_background_retry();
17460 return Err(SectionError::Abort(
17461 "sync.invalid_request".to_owned(),
17462 "segment bytes do not match the content address (§5.1)".to_owned(),
17463 ));
17464 }
17465 self.progress.update(|p| p.phase = ProgressPhase::Import);
17466 if media_type == MediaType::Sqlite {
17467 if row_cursor.is_some() || next_row_cursor.is_some() {
17470 return Err(SectionError::Abort(
17471 "sync.invalid_request".to_owned(),
17472 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
17473 .to_owned(),
17474 ));
17475 }
17476 let first = !saw_segment;
17477 saw_segment = true;
17478 let applied = self.apply_sqlite_segment(
17479 sub_index,
17480 &bytes,
17481 fresh && first,
17482 row_count,
17483 as_of_commit_seq,
17484 &scope_digest,
17485 )?;
17486 report.segment_rows_applied += applied;
17487 } else {
17488 let segment = decode_rows_segment(&bytes).map_err(|e| {
17489 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
17490 })?;
17491 let first = row_cursor.is_none();
17492 saw_segment = true;
17493 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
17494 report.segment_rows_applied += applied;
17495 }
17496 }
17497 Frame::Unknown { .. } => {}
17498 Frame::Error { code, message, .. } => {
17499 return Err(SectionError::Abort(code, message))
17500 }
17501 _ => {
17502 return Err(SectionError::Abort(
17503 "sync.invalid_request".to_owned(),
17504 "unexpected frame inside a subscription section".to_owned(),
17505 ));
17506 }
17507 }
17508 }
17509 Ok(())
17510 }
17511
17512 fn apply_commit_frame(
17514 &mut self,
17515 tables: &[String],
17516 changes: &[ssp2::model::Change],
17517 commit_seq: Option<i64>,
17518 ) -> Result<(), (String, String)> {
17519 #[cfg(feature = "bench-internals")]
17520 let _phase = self.benchmark_phases.start(Phase::CommitApply);
17521 let was_dirty = self.overlay_dirty.snapshot();
17522 self.coalesce_unit(changes.len())
17523 .and_then(|()| self.begin_observation("syncular_commit"))
17524 .map_err(|message| ("storage.failed".into(), message))?;
17525 let applied = (|| {
17526 let mut batch = ChangeAccumulator::default();
17527 self.record_commit_changes(&mut batch, tables, changes);
17528 if let Some(sequence) = commit_seq {
17529 for change in changes {
17530 let table = tables.get(change.table_index as usize).ok_or_else(|| {
17531 (
17532 "sync.invalid_request".to_owned(),
17533 "change tableIndex out of range".to_owned(),
17534 )
17535 })?;
17536 if self
17537 .outbox
17538 .iter()
17539 .flat_map(|commit| &commit.ops)
17540 .any(|op| op.table == *table && op.row_id == change.row_id)
17541 {
17542 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])
17543 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
17544 }
17545 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])
17546 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
17547 }
17548 }
17549 self.apply_commit_changes(tables, changes)?;
17550 self.rebuild_overlay_if_dirty()
17551 .map_err(|message| ("storage.failed".into(), message))?;
17552 self.finish_observation("syncular_commit", batch)
17553 .map_err(|message| ("storage.failed".into(), message))
17554 })();
17555 if applied.is_err() {
17556 self.rollback_observation("syncular_commit");
17557 self.overlay_dirty.restore(was_dirty.clone());
17558 }
17559 applied
17560 }
17561
17562 fn apply_commit_changes(
17563 &mut self,
17564 tables: &[String],
17565 changes: &[ssp2::model::Change],
17566 ) -> Result<(), (String, String)> {
17567 let mirror_visible = !self.overlay_dirty.get()
17572 && self.failed_commits.is_empty()
17573 && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
17574 || changes.iter().all(|change| {
17575 tables
17576 .get(change.table_index as usize)
17577 .and_then(|name| self.schema.table(name))
17578 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
17579 }));
17580 for change in changes {
17581 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
17582 (
17583 "sync.invalid_request".to_owned(),
17584 "change tableIndex out of range".to_owned(),
17585 )
17586 })?;
17587 let table = self.schema.table(table_name).ok_or_else(|| {
17588 (
17589 "sync.schema_mismatch".to_owned(),
17590 format!("change targets unknown table {table_name:?}"),
17591 )
17592 })?;
17593 match change.op {
17594 Op::Upsert => {
17595 let payload = change.row.as_ref().ok_or_else(|| {
17596 (
17597 "sync.invalid_request".to_owned(),
17598 "upsert change without row payload".to_owned(),
17599 )
17600 })?;
17601 let row = {
17603 #[cfg(feature = "bench-internals")]
17604 let _phase = self.benchmark_phases.start(Phase::RowDecode);
17605 decode_row_bytes(table, payload, &self.encryption)
17606 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
17607 };
17608 let version = change.row_version.unwrap_or(0);
17609 let table_name = table.name.clone();
17610 self.write_base_row(&table_name, &row, version)
17611 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17612 if mirror_visible {
17613 self.write_row(&visible_table(&table_name), &table_name, &row, version)
17614 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17615 }
17616 }
17617 Op::Delete => {
17618 self.delete_base_row(table_name, &change.row_id)
17619 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17620 if mirror_visible {
17621 let sql = format!(
17622 "DELETE FROM {} WHERE {}",
17623 visible_table(table_name),
17624 row_id_predicate(table)
17625 );
17626 self.conn
17627 .prepare_cached(&sql)
17628 .and_then(|mut statement| {
17629 statement.execute(rusqlite::params![change.row_id])
17630 })
17631 .map_err(|error| {
17632 ("sync.invalid_request".to_owned(), error.to_string())
17633 })?;
17634 }
17635 }
17636 }
17637 }
17638 if mirror_visible {
17639 {
17640 let changed_rows: HashSet<(&str, &str)> = changes
17641 .iter()
17642 .map(|change| {
17643 (
17644 tables[change.table_index as usize].as_str(),
17645 change.row_id.as_str(),
17646 )
17647 })
17648 .collect();
17649 let acknowledged = self
17650 .acknowledged_operations(Some(&changed_rows), None)
17651 .map_err(|error| ("storage.failed".to_owned(), error))?;
17652 self.apply_outbox_ops(&acknowledged)
17653 .map_err(|message| ("storage.failed".into(), message))?;
17654 self.apply_outbox_ops(
17655 self.outbox
17656 .iter()
17657 .flat_map(|commit| &commit.ops)
17658 .filter(|op| {
17659 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
17660 }),
17661 )
17662 .map_err(|message| ("storage.failed".into(), message))?;
17663 }
17664 self.overlay_dirty.set(false);
17665 }
17666 Ok(())
17667 }
17668
17669 fn apply_segment(
17674 &mut self,
17675 sub_index: usize,
17676 segment: &RowsSegment,
17677 first_fresh_page: bool,
17678 ) -> Result<u32, SectionError> {
17679 let (sub_table, effective) = {
17680 let sub = &self.subs[sub_index];
17681 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17682 };
17683 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
17684 SectionError::Abort(
17685 "sync.schema_mismatch".to_owned(),
17686 format!("subscription table {sub_table:?} is not in the client schema"),
17687 )
17688 })?;
17689 let matches = segment.table == table.name
17694 && segment.schema_version == self.schema.version
17695 && segment.columns.len() == table.wire_columns.len()
17696 && segment
17697 .columns
17698 .iter()
17699 .zip(table.wire_columns.iter())
17700 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
17701 if !matches {
17702 return Err(SectionError::Abort(
17703 "sync.schema_mismatch".to_owned(),
17704 "segment column table does not match the generated schema (§5.2)".to_owned(),
17705 ));
17706 }
17707 let mut applied = 0u32;
17708 let empty = Vec::new();
17709 let blocks = if segment.blocks.is_empty() {
17710 std::slice::from_ref(&empty)
17711 } else {
17712 &segment.blocks
17713 };
17714 for (index, block) in blocks.iter().enumerate() {
17715 let clear = first_fresh_page && index == 0;
17716 if !clear && block.is_empty() {
17717 continue;
17718 }
17719 let was_dirty = self.overlay_dirty.snapshot();
17720 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
17721 && self
17722 .outbox
17723 .iter()
17724 .flat_map(|commit| &commit.ops)
17725 .any(|op| op.table == table.name);
17726 let mirror_visible = !reconcile_pending
17727 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
17728 self.coalesce_unit(block.len())
17729 .and_then(|()| self.begin_observation("syncular_segment_block"))
17730 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17731 let outcome = (|| {
17732 self.rebuild_overlay_if_dirty()
17733 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17734 let mut changed_keys = Vec::new();
17735 let mut batch = ChangeAccumulator::default();
17736 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
17737 batch.table(&table.name);
17738 }
17739 if clear {
17740 self.purge_scope_rows(&table.name, &effective)
17741 .map_err(|()| SectionError::FailClosed)?;
17742 }
17743 for (row_index, row) in block.iter().enumerate() {
17744 let decrypted;
17745 let values = if table.has_encrypted_columns() {
17746 let mut values = row.values.clone();
17747 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
17748 .map_err(|message| {
17749 SectionError::Abort("client.decrypt_failed".into(), message)
17750 })?;
17751 decrypted = values;
17752 &decrypted
17753 } else {
17754 &row.values
17755 };
17756 self.write_base_row(&table.name, values, row.server_version)
17757 .map_err(|message| {
17758 SectionError::Abort("sync.invalid_request".into(), message)
17759 })?;
17760 if reconcile_pending {
17761 changed_keys.push(
17762 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
17763 |message| SectionError::Abort("storage.failed".into(), message),
17764 )?,
17765 );
17766 }
17767 if mirror_visible {
17768 self.write_row(
17769 &visible_table(&table.name),
17770 &table.name,
17771 values,
17772 row.server_version,
17773 )
17774 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17775 }
17776 let processed = u64::from(applied) + row_index as u64 + 1;
17777 if processed.is_multiple_of(1024) {
17778 self.progress.update(|p| p.rows_processed = processed);
17779 }
17780 }
17781 if reconcile_pending {
17782 self.reconcile_imported_rows(&table, &mut changed_keys)
17783 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17784 }
17785 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
17786 self.apply_outbox_ops(
17787 self.outbox
17788 .iter()
17789 .flat_map(|commit| &commit.ops)
17790 .filter(|op| op.table == table.name),
17791 )
17792 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17793 self.overlay_dirty.set(false);
17794 } else {
17795 self.rebuild_overlay_if_dirty()
17796 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17797 }
17798 self.finish_observation("syncular_segment_block", batch)
17799 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
17800 })();
17801 if let Err(error) = outcome {
17802 self.rollback_observation("syncular_segment_block");
17803 self.overlay_dirty.restore(was_dirty.clone());
17804 return Err(error);
17805 }
17806 applied += block.len() as u32;
17807 self.progress
17808 .update(|p| p.rows_processed = u64::from(applied));
17809 std::thread::yield_now();
17810 }
17811 self.progress
17812 .update(|p| p.rows_processed = u64::from(applied));
17813 Ok(applied)
17814 }
17815
17816 fn apply_sqlite_segment(
17824 &mut self,
17825 sub_index: usize,
17826 bytes: &[u8],
17827 first_fresh_page: bool,
17828 row_count: i64,
17829 as_of_commit_seq: i64,
17830 scope_digest: &str,
17831 ) -> Result<u32, SectionError> {
17832 let invalid = |detail: &str| {
17833 SectionError::Abort(
17834 "sync.invalid_request".to_owned(),
17835 format!("sqlite segment rejected: {detail} (§5.3)"),
17836 )
17837 };
17838 let (sub_table, effective) = {
17839 let sub = &self.subs[sub_index];
17840 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17841 };
17842 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
17843 SectionError::Abort(
17844 "sync.schema_mismatch".to_owned(),
17845 format!("subscription table {sub_table:?} is not in the client schema"),
17846 )
17847 })?;
17848
17849 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
17850 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
17851 let img = match rusqlite::Connection::open_with_flags(
17852 &path,
17853 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
17854 ) {
17855 Ok(conn) => conn,
17856 Err(_) => {
17857 let _ = std::fs::remove_file(&path);
17858 return Err(invalid("bytes do not open as a SQLite database"));
17859 }
17860 };
17861 let outcome = self.apply_sqlite_image(
17862 &img,
17863 &table,
17864 first_fresh_page,
17865 &effective,
17866 row_count,
17867 as_of_commit_seq,
17868 scope_digest,
17869 );
17870 drop(img);
17871 let _ = std::fs::remove_file(&path);
17872 outcome
17873 }
17874
17875 #[allow(clippy::too_many_arguments)]
17876 fn apply_sqlite_image(
17877 &mut self,
17878 img: &rusqlite::Connection,
17879 table: &crate::schema::TableSchema,
17880 first_fresh_page: bool,
17881 effective: &[(String, Vec<String>)],
17882 row_count: i64,
17883 as_of_commit_seq: i64,
17884 scope_digest: &str,
17885 ) -> Result<u32, SectionError> {
17886 let invalid = |detail: String| {
17887 SectionError::Abort(
17888 "sync.invalid_request".to_owned(),
17889 format!("sqlite segment rejected: {detail} (§5.3)"),
17890 )
17891 };
17892
17893 type MetaRow = (i64, String, i64, i64, String, i64, i64);
17896 let meta: MetaRow = img
17897 .query_row(
17898 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
17899 \"scopeDigest\", \"rowCount\",
17900 (SELECT count(*) FROM _syncular_segment)
17901 FROM _syncular_segment",
17902 [],
17903 |row| {
17904 Ok((
17905 row.get(0)?,
17906 row.get(1)?,
17907 row.get(2)?,
17908 row.get(3)?,
17909 row.get(4)?,
17910 row.get(5)?,
17911 row.get(6)?,
17912 ))
17913 },
17914 )
17915 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
17916 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
17917 if meta_count != 1 {
17918 return Err(invalid(format!(
17919 "_syncular_segment must contain exactly one row, found {meta_count}"
17920 )));
17921 }
17922 if format != 1 {
17923 return Err(invalid(format!("format {format}")));
17924 }
17925 if meta_table != table.name {
17926 return Err(invalid(format!("image table {meta_table:?}")));
17927 }
17928 if schema_version != i64::from(self.schema.version) {
17929 return Err(invalid(format!("schemaVersion {schema_version}")));
17930 }
17931 if pin != as_of_commit_seq {
17932 return Err(invalid(format!("asOfCommitSeq {pin}")));
17933 }
17934 if digest != scope_digest {
17935 return Err(invalid("scopeDigest mismatch".to_owned()));
17936 }
17937 if meta_rows != row_count {
17938 return Err(invalid(format!("rowCount {meta_rows}")));
17939 }
17940
17941 let mut names: Vec<String> = Vec::new();
17943 let mut primary_keys: Vec<String> = Vec::new();
17944 {
17945 let mut stmt = img
17946 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
17947 .map_err(|_| invalid("image data table missing".to_owned()))?;
17948 let mut rows = stmt
17949 .query([])
17950 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
17951 while let Some(row) = rows
17952 .next()
17953 .map_err(|_| invalid("image data table unreadable".to_owned()))?
17954 {
17955 if row
17956 .get::<_, i64>(5)
17957 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
17958 > 0
17959 {
17960 primary_keys.push(row.get(1).map_err(|error| {
17961 invalid(Self::sqlite_failure(&self.storage_failure, error))
17962 })?);
17963 }
17964 names.push(
17965 row.get::<_, String>(1)
17966 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
17967 );
17968 }
17969 }
17970 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
17971 expected.push("_syncular_version");
17972 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
17973 return Err(SectionError::Abort(
17974 "sync.schema_mismatch".to_owned(),
17975 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
17976 ));
17977 }
17978
17979 if primary_keys != vec![table.primary_key.clone()] {
17980 return Err(invalid(
17981 "image primary key does not match generated schema".into(),
17982 ));
17983 }
17984 let actual_count: i64 = img
17985 .query_row(
17986 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
17987 [],
17988 |row| row.get(0),
17989 )
17990 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17991 if actual_count != row_count {
17992 return Err(invalid("image row count does not match descriptor".into()));
17993 }
17994 let insert = self.insert_row_sql(&base_table(&table.name), table);
17995 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
17996 let column_list = names
17997 .iter()
17998 .map(|n| quote_ident(n))
17999 .collect::<Vec<_>>()
18000 .join(", ");
18001 let mut stmt = img
18002 .prepare(&format!(
18003 "SELECT {column_list} FROM {} ORDER BY {}",
18004 quote_ident(&table.name),
18005 quote_ident(&table.primary_key)
18006 ))
18007 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18008 let mut rows = stmt
18009 .query([])
18010 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18011 let mut applied = 0u32;
18012 loop {
18013 let clear = applied == 0 && first_fresh_page;
18014 let was_dirty = self.overlay_dirty.snapshot();
18015 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
18016 && self
18017 .outbox
18018 .iter()
18019 .flat_map(|commit| &commit.ops)
18020 .any(|op| op.table == table.name);
18021 let mirror_visible = !reconcile_pending
18022 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
18023 self.coalesce_unit(1024)
18025 .and_then(|()| self.begin_observation("syncular_image_chunk"))
18026 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18027 let result = (|| {
18028 self.rebuild_overlay_if_dirty()
18029 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18030 let mut changed_keys = Vec::new();
18031 let mut batch = ChangeAccumulator::default();
18032 if clear {
18033 if self.scoped_rows_exist(&table.name, effective) {
18034 batch.table(&table.name);
18035 }
18036 self.purge_scope_rows(&table.name, effective)
18037 .map_err(|()| SectionError::FailClosed)?;
18038 }
18039 let mut processed = 0u32;
18040 {
18041 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
18042 invalid(Self::sqlite_failure(&self.storage_failure, error))
18043 })?;
18044 let mut mirror = if mirror_visible {
18045 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
18046 invalid(Self::sqlite_failure(&self.storage_failure, error))
18047 })?)
18048 } else {
18049 None
18050 };
18051 for _ in 0..1024 {
18052 let Some(row) = rows.next().map_err(|error| {
18053 invalid(Self::sqlite_failure(&self.storage_failure, error))
18054 })?
18055 else {
18056 break;
18057 };
18058 for (i, column) in table.columns.iter().enumerate() {
18059 let cell = row.get_ref(i).map_err(|error| {
18060 invalid(Self::sqlite_failure(&self.storage_failure, error))
18061 })?;
18062 let param = image_cell_param(column, cell).map_err(invalid)?;
18063 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
18064 invalid(Self::sqlite_failure(&self.storage_failure, error))
18065 })?;
18066 if let Some(mirror) = &mut mirror {
18067 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
18068 invalid(Self::sqlite_failure(&self.storage_failure, error))
18069 })?;
18070 }
18071 }
18072 let version_index = table.columns.len();
18073 let version: i64 = row.get(version_index).map_err(|error| {
18074 invalid(Self::sqlite_failure(&self.storage_failure, error))
18075 })?;
18076 if version < 1 {
18077 return Err(invalid("image row version must be positive".into()));
18078 }
18079 ins.raw_bind_parameter(version_index + 1, version)
18080 .map_err(|error| {
18081 invalid(Self::sqlite_failure(&self.storage_failure, error))
18082 })?;
18083 ins.raw_execute().map_err(|error| {
18084 invalid(Self::sqlite_failure(&self.storage_failure, error))
18085 })?;
18086 if let Some(mirror) = &mut mirror {
18087 mirror
18088 .raw_bind_parameter(version_index + 1, version)
18089 .map_err(|error| {
18090 invalid(Self::sqlite_failure(&self.storage_failure, error))
18091 })?;
18092 mirror.raw_execute().map_err(|error| {
18093 invalid(Self::sqlite_failure(&self.storage_failure, error))
18094 })?;
18095 }
18096 if reconcile_pending {
18097 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
18098 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
18099 )?);
18100 }
18101 processed += 1;
18102 }
18103 }
18104 if processed > 0 {
18105 batch.table(&table.name);
18106 self.overlay_dirty.table(&table.name);
18107 }
18108 if reconcile_pending {
18109 self.reconcile_imported_rows(table, &mut changed_keys)
18110 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18111 }
18112 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
18113 self.apply_outbox_ops(
18114 self.outbox
18115 .iter()
18116 .flat_map(|commit| &commit.ops)
18117 .filter(|op| op.table == table.name),
18118 )
18119 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18120 self.overlay_dirty.set(false);
18121 } else {
18122 self.rebuild_overlay_if_dirty()
18123 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18124 }
18125 self.finish_observation("syncular_image_chunk", batch)
18126 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18127 Ok(processed)
18128 })();
18129 let processed = match result {
18130 Ok(processed) => processed,
18131 Err(error) => {
18132 self.rollback_observation("syncular_image_chunk");
18133 self.overlay_dirty.restore(was_dirty.clone());
18134 return Err(error);
18135 }
18136 };
18137 applied += processed;
18138 self.progress
18139 .update(|p| p.rows_processed = u64::from(applied));
18140 std::thread::yield_now();
18141 if i64::from(applied) == actual_count {
18142 break;
18143 }
18144 }
18145 Ok(applied)
18146 }
18147
18148 fn reconcile_imported_rows(
18152 &self,
18153 table: &TableSchema,
18154 keys: &mut Vec<SqlValue>,
18155 ) -> Result<(), String> {
18156 let visible = visible_table(&table.name);
18157 let base = base_table(&table.name);
18158 let pk = quote_ident(&table.primary_key);
18159 let predicate = row_id_predicate(table);
18160 let mut lookup = self.conn.prepare_cached(&format!(
18161 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
18162 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18163 let mut deleted = HashSet::new();
18164 for op in self
18165 .outbox
18166 .iter()
18167 .flat_map(|commit| &commit.ops)
18168 .filter(|op| op.table == table.name)
18169 {
18170 if op.upsert {
18171 if let Some(values) = &op.values {
18172 let key = json_to_column_value(
18173 &table.columns[table.pk_index],
18174 values.get(&table.primary_key),
18175 )?;
18176 keys.push(owned_sql_value(RowParam::Cell(&key))?);
18177 }
18178 } else if deleted.insert(&op.row_id) {
18179 keys.extend(
18180 lookup
18181 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
18182 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
18183 .collect::<Result<Vec<_>, _>>()
18184 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
18185 );
18186 }
18187 }
18188 for chunk in keys.chunks(400) {
18191 let holes = vec!["?"; chunk.len()].join(",");
18192 self.conn
18193 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
18194 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
18195 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18196 }
18197 let updates = table
18198 .columns
18199 .iter()
18200 .map(|column| column.name.as_str())
18201 .chain(std::iter::once("_syncular_version"))
18202 .filter(|name| *name != table.primary_key)
18203 .map(|name| {
18204 let name = quote_ident(name);
18205 format!("{name}=excluded.{name}")
18206 })
18207 .collect::<Vec<_>>()
18208 .join(",");
18209 for chunk in keys.chunks(400) {
18210 let holes = vec!["?"; chunk.len()].join(",");
18211 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
18212 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
18213 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18214 }
18215 Ok(())
18216 }
18217
18218 fn compile_local_data_purge(
18221 &self,
18222 input: &LocalDataPurgeInput,
18223 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
18224 let invalid = |message: String| format!("sync.invalid_request: {message}");
18225 if input.purge_id.is_empty()
18226 || input.purge_id.len() > 128
18227 || !is_local_operation_code_like(&input.purge_id)
18228 {
18229 return Err(invalid(
18230 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
18231 ));
18232 }
18233 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
18234 return Err(invalid(format!(
18235 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
18236 )));
18237 }
18238
18239 let mut deduplicated: BTreeMap<
18240 String,
18241 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
18242 > = BTreeMap::new();
18243 for target in &input.targets {
18244 let table = self.schema.table(&target.table).ok_or_else(|| {
18245 invalid(format!(
18246 "local purge names unknown table {:?}",
18247 target.table
18248 ))
18249 })?;
18250 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
18251 return Err(invalid(format!(
18252 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
18253 target.table
18254 )));
18255 }
18256 let mut selectors = Vec::with_capacity(target.selectors.len());
18257 let mut canonical_selectors = BTreeMap::new();
18258 for (column_name, raw_values) in &target.selectors {
18259 let Some((column_index, column)) = table
18260 .columns
18261 .iter()
18262 .enumerate()
18263 .find(|(_, column)| column.name == *column_name)
18264 else {
18265 return Err(invalid(format!(
18266 "local purge target {:?} names unknown column {:?}",
18267 target.table, column_name
18268 )));
18269 };
18270 let encrypted = table
18271 .encrypted_columns
18272 .iter()
18273 .any(|candidate| candidate.index == column_index);
18274 if column.ty != ColumnType::String || encrypted {
18275 return Err(invalid(format!(
18276 "local purge selector {:?}.{:?} must be a plaintext string column",
18277 target.table, column_name
18278 )));
18279 }
18280 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
18281 return Err(invalid(format!(
18282 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
18283 target.table, column_name
18284 )));
18285 }
18286 let mut values = raw_values.clone();
18287 values.sort();
18288 values.dedup();
18289 if values.iter().any(|value| {
18290 value.is_empty()
18291 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
18292 || !is_local_operation_code_like(value)
18293 }) {
18294 return Err(invalid(format!(
18295 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
18296 )));
18297 }
18298 selectors.push((column_name.clone(), values.clone()));
18299 canonical_selectors.insert(column_name.clone(), values);
18300 }
18301 let canonical = LocalDataPurgeTarget {
18302 table: target.table.clone(),
18303 selectors: canonical_selectors,
18304 };
18305 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
18306 deduplicated.insert(
18307 key,
18308 (
18309 CompiledLocalDataPurgeTarget {
18310 table: target.table.clone(),
18311 selectors,
18312 },
18313 canonical,
18314 ),
18315 );
18316 }
18317 let targets = deduplicated
18318 .values()
18319 .map(|(compiled, _)| compiled.clone())
18320 .collect::<Vec<_>>();
18321 let canonical = deduplicated
18322 .values()
18323 .map(|(_, target)| target.clone())
18324 .collect::<Vec<_>>();
18325 let canonical_plan =
18326 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
18327 Ok((targets, canonical_plan))
18328 }
18329
18330 fn local_purge_base_row_ids(
18331 &self,
18332 targets: &[CompiledLocalDataPurgeTarget],
18333 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
18334 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
18335 for target in targets {
18336 let table = self
18337 .schema
18338 .table(&target.table)
18339 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
18340 let mut clauses = Vec::with_capacity(target.selectors.len());
18341 let mut params = Vec::new();
18342 for (column, values) in &target.selectors {
18343 clauses.push(format!(
18344 "{} IN ({})",
18345 quote_ident(column),
18346 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
18347 ));
18348 params.extend(values.iter().cloned().map(SqlValue::Text));
18349 }
18350 let sql = format!(
18351 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
18352 quote_ident(&table.primary_key),
18353 base_table(&target.table),
18354 clauses.join(" AND ")
18355 );
18356 let mut statement = self
18357 .conn
18358 .prepare(&sql)
18359 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18360 let rows = statement
18361 .query_map(rusqlite::params_from_iter(params), |row| {
18362 let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
18363 render_row_id_json(Some(&value))
18364 .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
18365 })
18366 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18367 let ids = by_table.entry(target.table.clone()).or_default();
18368 for row in rows {
18369 ids.insert(
18370 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
18371 );
18372 }
18373 }
18374 Ok(by_table)
18375 }
18376
18377 fn local_purge_values_match(
18378 target: &CompiledLocalDataPurgeTarget,
18379 values: &Map<String, Value>,
18380 ) -> bool {
18381 target.selectors.iter().all(|(column, allowed)| {
18382 values
18383 .get(column)
18384 .and_then(Value::as_str)
18385 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
18386 })
18387 }
18388
18389 pub fn purge_local_data(
18393 &mut self,
18394 input: &LocalDataPurgeInput,
18395 ) -> Result<LocalDataPurgeResult, String> {
18396 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
18397 let meta_key = format!("localPurge:{}", input.purge_id);
18398 let applied_plan = self
18399 .conn
18400 .query_row(
18401 "SELECT value FROM _syncular_meta WHERE key = ?1",
18402 rusqlite::params![meta_key],
18403 |row| row.get::<_, String>(0),
18404 )
18405 .optional()
18406 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18407 if let Some(applied_plan) = applied_plan {
18408 if applied_plan != canonical_plan {
18409 return Err(format!(
18410 "sync.invalid_request: local purge id {:?} was already used with a different plan",
18411 input.purge_id
18412 ));
18413 }
18414 return Ok(LocalDataPurgeResult {
18415 already_applied: true,
18416 purged_rows: 0,
18417 dropped_commits: 0,
18418 });
18419 }
18420
18421 let _ = self.drop_previous_version();
18425
18426 let prior_outbox = self.outbox.clone();
18427 let prior_failed = self.failed_commits.clone();
18428 let prior_rejections = self.rejections.clone();
18429 let prior_conflicts = self.conflicts.clone();
18430 let prior_overlay_dirty = self.overlay_dirty.snapshot();
18431 self.begin_observation("syncular_local_purge")?;
18432 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
18433 let row_ids = self.local_purge_base_row_ids(&targets)?;
18434 let retained_dropped = self.drop_failed_commits(|table, values| {
18435 targets.iter().any(|target| {
18436 target.table == table && Self::local_purge_values_match(target, values)
18437 })
18438 })?;
18439 let doomed = self
18440 .outbox
18441 .iter()
18442 .filter(|commit| {
18443 commit.ops.iter().any(|operation| {
18444 let matching_targets = targets
18445 .iter()
18446 .filter(|target| target.table == operation.table)
18447 .collect::<Vec<_>>();
18448 if matching_targets.is_empty() {
18449 return false;
18450 }
18451 if row_ids
18452 .get(&operation.table)
18453 .is_some_and(|ids| ids.contains(&operation.row_id))
18454 {
18455 return true;
18456 }
18457 operation.values.as_ref().is_some_and(|values| {
18458 matching_targets
18459 .iter()
18460 .any(|target| Self::local_purge_values_match(target, values))
18461 })
18462 })
18463 })
18464 .cloned()
18465 .collect::<Vec<_>>();
18466 let doomed_ids = doomed
18467 .iter()
18468 .map(|commit| commit.client_commit_id.clone())
18469 .collect::<BTreeSet<_>>();
18470 let mut batch = ChangeAccumulator::default();
18471 for commit in &retained_dropped {
18472 batch.conflicts = true;
18473 batch.rejections = true;
18474 batch.outcomes = true;
18475 for operation in &commit.ops {
18476 batch.table(&operation.table);
18477 }
18478 }
18479 let mut rejections = Vec::new();
18480 for commit in &doomed {
18481 for operation in &commit.ops {
18482 batch.table(&operation.table);
18483 }
18484 let results = commit
18485 .ops
18486 .iter()
18487 .enumerate()
18488 .map(|(op_index, operation)| {
18489 let rejection = RejectionRecord {
18490 client_commit_id: commit.client_commit_id.clone(),
18491 op_index: op_index as i32,
18492 code: "client.local_data_purged".to_owned(),
18493 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
18494 retryable: false,
18495 details: None,
18496 operation: Some(CommitOperation::from(operation)),
18497 };
18498 rejections.push(rejection.clone());
18499 CommitOperationOutcome::Error { rejection }
18500 })
18501 .collect::<Vec<_>>();
18502 self.persist_commit_outcome(
18503 &commit.client_commit_id,
18504 CommitOutcomeStatus::Rejected,
18505 &results,
18506 Some(&commit.ops),
18507 )?;
18508 self.delete_outbox_persisted(&commit.client_commit_id)?;
18509 }
18510 if !doomed.is_empty() {
18511 self.outbox
18512 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
18513 self.rejections.extend(rejections);
18514 self.prune_commit_outcomes()?;
18515 batch.status = true;
18516 batch.rejections = true;
18517 batch.outcomes = true;
18518 }
18519
18520 let mut purged_rows = 0usize;
18521 for (table, ids) in &row_ids {
18522 if ids.is_empty() {
18523 continue;
18524 }
18525 batch.table(table);
18526 for row_id in ids {
18527 self.delete_base_row(table, row_id)?;
18528 purged_rows += 1;
18529 }
18530 }
18531 self.rebuild_overlay_if_dirty()?;
18532 self.delete_unreferenced_cached_blobs()?;
18533 self.conn
18534 .execute(
18535 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
18536 rusqlite::params![meta_key, canonical_plan],
18537 )
18538 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18539 Ok((
18540 batch,
18541 LocalDataPurgeResult {
18542 already_applied: false,
18543 purged_rows,
18544 dropped_commits: doomed.len(),
18545 },
18546 ))
18547 })();
18548 let (batch, result) = match applied {
18549 Ok(value) => value,
18550 Err(error) => {
18551 self.rollback_observation("syncular_local_purge");
18552 self.outbox = prior_outbox;
18553 self.failed_commits = prior_failed;
18554 self.rejections = prior_rejections;
18555 self.conflicts = prior_conflicts;
18556 self.overlay_dirty.restore(prior_overlay_dirty.clone());
18557 return Err(error);
18558 }
18559 };
18560 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
18561 self.rollback_observation("syncular_local_purge");
18562 self.outbox = prior_outbox;
18563 self.failed_commits = prior_failed;
18564 self.rejections = prior_rejections;
18565 self.conflicts = prior_conflicts;
18566 self.overlay_dirty.restore(prior_overlay_dirty.clone());
18567 return Err(error);
18568 }
18569 self.cancel_sync_round();
18570 Ok(result)
18571 }
18572
18573 pub fn rebootstrap_local_data(
18579 &mut self,
18580 input: &LocalDataRebootstrapInput,
18581 ) -> Result<LocalDataRebootstrapResult, String> {
18582 if input.rebootstrap_id.is_empty()
18583 || input.rebootstrap_id.len() > 128
18584 || !is_local_operation_code_like(&input.rebootstrap_id)
18585 {
18586 return Err(
18587 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
18588 .to_owned(),
18589 );
18590 }
18591 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
18592 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
18593 let (retained_commits, reset_subscriptions) =
18594 decode_local_rebootstrap_receipt(&persisted)?;
18595 return Ok(LocalDataRebootstrapResult {
18596 already_applied: true,
18597 retained_commits,
18598 reset_subscriptions,
18599 });
18600 }
18601 if self.stopped || self.schema_floor.is_some() {
18602 return Err(
18603 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
18604 .to_owned(),
18605 );
18606 }
18607
18608 let retained_commits = self.outbox.len();
18609 let reset_subscriptions = self.subs.len();
18610 let prior_subs = self.subs.clone();
18611 let prior_upgrading = self.upgrading;
18612 let prior_stopped = self.stopped;
18613 let prior_schema_floor = self.schema_floor.clone();
18614 let prior_overlay_dirty = self.overlay_dirty.snapshot();
18615 let prior_sync_needed = self.sync_needed;
18616 let prior_sync_intents = self.sync_intent_queue.clone();
18617 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
18618
18619 let prior_active_round = self.active_round;
18620 self.begin_observation("syncular_local_rebootstrap")?;
18621 let mut batch = ChangeAccumulator::default();
18622 let applied = (|| -> Result<(), String> {
18623 self.run_schema_reset_observed(&mut batch, false)?;
18624 self.conn
18625 .execute(
18626 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
18627 rusqlite::params![meta_key, receipt],
18628 )
18629 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18630 self.sync_needed = true;
18631 self.sync_intent_queue.push_back(SyncIntent::Interactive);
18632 batch.status = true;
18633 Ok(())
18634 })();
18635 if let Err(error) = applied {
18636 self.rollback_observation("syncular_local_rebootstrap");
18637 self.active_round = prior_active_round;
18638 self.subs = prior_subs;
18639 self.upgrading = prior_upgrading;
18640 self.stopped = prior_stopped;
18641 self.schema_floor = prior_schema_floor;
18642 self.overlay_dirty.restore(prior_overlay_dirty.clone());
18643 self.sync_needed = prior_sync_needed;
18644 self.sync_intent_queue = prior_sync_intents;
18645 return Err(error);
18646 }
18647 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
18648 self.rollback_observation("syncular_local_rebootstrap");
18649 self.active_round = prior_active_round;
18650 self.subs = prior_subs;
18651 self.upgrading = prior_upgrading;
18652 self.stopped = prior_stopped;
18653 self.schema_floor = prior_schema_floor;
18654 self.overlay_dirty.restore(prior_overlay_dirty.clone());
18655 self.sync_needed = prior_sync_needed;
18656 self.sync_intent_queue = prior_sync_intents;
18657 return Err(error);
18658 }
18659
18660 Ok(LocalDataRebootstrapResult {
18661 already_applied: false,
18662 retained_commits,
18663 reset_subscriptions,
18664 })
18665 }
18666
18667 fn purge_scope_rows(
18672 &mut self,
18673 table_name: &str,
18674 effective: &[(String, Vec<String>)],
18675 ) -> Result<(), ()> {
18676 if effective.is_empty() {
18677 return Ok(());
18678 }
18679 let table = self.schema.table(table_name).ok_or(())?.clone();
18680 let mut clauses = Vec::new();
18681 let mut params: Vec<SqlValue> = Vec::new();
18682 for (variable, values) in effective {
18683 let column = table.scope_column(variable).ok_or(())?;
18684 let placeholders: Vec<String> = values
18685 .iter()
18686 .map(|v| {
18687 params.push(SqlValue::Text(v.clone()));
18688 "?".to_owned()
18689 })
18690 .collect();
18691 clauses.push(format!(
18692 "{} IN ({})",
18693 quote_ident(column),
18694 placeholders.join(", ")
18695 ));
18696 }
18697 let sql = format!(
18698 "DELETE FROM {} WHERE {}",
18699 base_table(table_name),
18700 clauses.join(" AND ")
18701 );
18702 self.overlay_dirty.table(table_name);
18703 self.conn
18704 .execute(&sql, rusqlite::params_from_iter(¶ms))
18705 .map_err(|_| ())?;
18706 self.conn
18707 .execute(
18708 &format!(
18709 "DELETE FROM {} WHERE {}",
18710 visible_table(table_name),
18711 clauses.join(" AND ")
18712 ),
18713 rusqlite::params_from_iter(¶ms),
18714 )
18715 .map_err(|_| ())?;
18716 Ok(())
18717 }
18718
18719 fn drop_doomed_outbox(
18722 &mut self,
18723 table_name: &str,
18724 effective: &[(String, Vec<String>)],
18725 ) -> Result<bool, String> {
18726 if effective.is_empty() {
18727 return Ok(false);
18728 }
18729 let Some(table) = self.schema.table(table_name).cloned() else {
18730 return Ok(false);
18731 };
18732 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
18733 for (variable, values) in effective {
18734 match table.scope_column(variable) {
18735 Some(column) => mappings.push((column, values)),
18736 None => return Ok(false), }
18738 }
18739 let doomed: Vec<OutboxCommit> = self
18740 .outbox
18741 .iter()
18742 .filter(|commit| {
18743 commit.ops.iter().any(|op| {
18744 op.upsert
18745 && op.table == table_name
18746 && op.values.as_ref().is_some_and(|values| {
18747 mappings
18748 .iter()
18749 .all(|(column, allowed)| match values.get(*column) {
18750 Some(Value::String(s)) => allowed.contains(s),
18751 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
18752 Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
18753 _ => false,
18754 })
18755 })
18756 })
18757 })
18758 .cloned()
18759 .collect();
18760 if doomed.is_empty() {
18761 return Ok(false);
18762 }
18763 let mut rejections = Vec::new();
18764 for commit in &doomed {
18765 let results = commit
18766 .ops
18767 .iter()
18768 .enumerate()
18769 .map(|(op_index, operation)| {
18770 let rejection = RejectionRecord {
18771 client_commit_id: commit.client_commit_id.clone(),
18772 op_index: op_index as i32,
18773 code: "sync.scope_revoked".to_owned(),
18774 message: "the commit was dropped because its effective scope was revoked"
18775 .to_owned(),
18776 retryable: false,
18777 details: None,
18778 operation: Some(CommitOperation::from(operation)),
18779 };
18780 rejections.push(rejection.clone());
18781 CommitOperationOutcome::Error { rejection }
18782 })
18783 .collect::<Vec<_>>();
18784 self.persist_commit_outcome(
18785 &commit.client_commit_id,
18786 CommitOutcomeStatus::Rejected,
18787 &results,
18788 Some(&commit.ops),
18789 )?;
18790 self.delete_outbox_persisted(&commit.client_commit_id)?;
18791 }
18792 self.prune_commit_outcomes()?;
18793 let doomed_ids = doomed
18794 .iter()
18795 .map(|commit| commit.client_commit_id.as_str())
18796 .collect::<BTreeSet<_>>();
18797 self.outbox
18798 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
18799 self.rejections.extend(rejections);
18800 Ok(true)
18801 }
18802
18803 pub fn upload_blob(
18810 &mut self,
18811 bytes: &[u8],
18812 media_type: Option<String>,
18813 name: Option<String>,
18814 ) -> Result<Value, String> {
18815 let blob_id = blob_id_for(bytes);
18816 let now = self.clock_now_ms();
18817 let tx = self
18818 .conn
18819 .transaction()
18820 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18821 tx.execute(
18822 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
18823 ON CONFLICT(blob_id) DO NOTHING",
18824 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
18825 )
18826 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18827 tx.execute(
18828 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
18829 rusqlite::params![blob_id, media_type, now],
18830 )
18831 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18832 tx.commit()
18833 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18834 self.enforce_blob_cache_cap();
18835 let mut obj = Map::new();
18836 obj.insert("blobId".to_owned(), Value::from(blob_id));
18837 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
18838 if let Some(mt) = media_type {
18839 obj.insert("mediaType".to_owned(), Value::from(mt));
18840 }
18841 if let Some(n) = name {
18842 obj.insert("name".to_owned(), Value::from(n));
18843 }
18844 Ok(Value::Object(obj))
18845 }
18846
18847 pub fn fetch_blob_bytes(
18851 &mut self,
18852 transport: &mut dyn Transport,
18853 blob_id_or_ref: &str,
18854 ) -> Result<FetchedBlob, (String, String)> {
18855 let simple = |m: String| ("client.failed".to_owned(), m);
18856 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
18857 blob_id_or_ref.to_owned()
18858 } else {
18859 let value: Value = serde_json::from_str(blob_id_or_ref)
18860 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
18861 value
18862 .get("blobId")
18863 .and_then(Value::as_str)
18864 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
18865 .to_owned()
18866 };
18867 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
18868 return Ok(cached);
18869 }
18870 if !self.transport_enabled {
18871 return Err(("sync.offline".into(), "transport gate is closed".into()));
18872 }
18873 #[cfg(feature = "bench-internals")]
18879 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
18880 let bytes = match transport
18881 .blob_download(&blob_id)
18882 .map_err(|e| (e.code, e.message))?
18883 {
18884 BlobDownload::Bytes(bytes) => bytes,
18885 BlobDownload::Url {
18886 url,
18887 url_expires_at_ms,
18888 } => {
18889 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
18891 return Err((
18892 "sync.segment_expired".to_owned(),
18893 format!(
18894 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
18895 ),
18896 ));
18897 }
18898 transport
18899 .fetch_blob_url(&url)
18900 .map_err(|e| (e.code, e.message))?
18901 }
18902 };
18903 #[cfg(feature = "bench-internals")]
18904 drop(download_phase);
18905 #[cfg(feature = "bench-internals")]
18906 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
18907 if blob_id_for(&bytes) != blob_id {
18909 return Err(simple(format!(
18910 "blob content address mismatch for {blob_id}"
18911 )));
18912 }
18913 #[cfg(feature = "bench-internals")]
18914 drop(validate_phase);
18915 #[cfg(feature = "bench-internals")]
18916 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
18917 let now = self.clock_now_ms();
18918 self.conn
18919 .execute(
18920 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
18921 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
18922 )
18923 .map_err(|e| simple(e.to_string()))?;
18924 self.conn
18925 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
18926 .map_err(|e| simple(e.to_string()))?;
18927 self.enforce_blob_cache_cap();
18928 #[cfg(feature = "bench-internals")]
18929 drop(insert_phase);
18930 self.get_cached_blob_bytes(&blob_id)
18931 .map_err(simple)?
18932 .ok_or_else(|| simple("blob cache write failed".to_owned()))
18933 }
18934
18935 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
18936 #[cfg(feature = "bench-internals")]
18937 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
18938 let mut stmt = self
18939 .conn
18940 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
18941 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18942 let mut rows = stmt
18943 .query(rusqlite::params![blob_id])
18944 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18945 if let Some(row) = rows
18946 .next()
18947 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
18948 {
18949 let bytes: Vec<u8> = row
18950 .get(0)
18951 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18952 let byte_length: i64 = row
18953 .get(1)
18954 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18955 let media_type: Option<String> = row
18956 .get(2)
18957 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18958 return Ok(Some(FetchedBlob {
18959 blob_id: blob_id.to_owned(),
18960 byte_length,
18961 bytes,
18962 media_type,
18963 }));
18964 }
18965 Ok(None)
18966 }
18967
18968 fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
18970 let pending: Vec<(String, Option<String>)> = {
18971 let mut stmt = self
18972 .conn
18973 .prepare(
18974 "SELECT blob_id, media_type FROM (
18975 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
18976 UNION ALL
18977 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
18978 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
18979 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
18980 ) ORDER BY created_at_ms, blob_id",
18981 )
18982 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18983 let rows = stmt
18984 .query_map([], |row| {
18985 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
18986 })
18987 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18988 rows.collect::<Result<_, _>>()
18989 .map_err(|error| match error {
18990 rusqlite::Error::InvalidColumnType(..)
18991 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
18992 "sync.local_corrupt",
18993 "Pending blob upload metadata is invalid",
18994 ),
18995 _ => TransportError::new("client.failed", error.to_string()),
18996 })?
18997 };
18998 let mut uploads = Vec::new();
18999 for (blob_id, media_type) in pending {
19000 let bytes = (|| {
19001 let stored: Option<(SqlValue, SqlValue)> = self
19002 .conn
19003 .query_row(
19004 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
19005 rusqlite::params![blob_id],
19006 |row| Ok((row.get(0)?, row.get(1)?)),
19007 )
19008 .optional()
19009 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19010 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
19011 return Err(TransportError::new(
19012 "sync.local_corrupt",
19013 "Pending blob upload body is missing or corrupt",
19014 ));
19015 };
19016 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
19017 return Err(TransportError::new(
19018 "sync.local_corrupt",
19019 "Pending blob upload body is missing or corrupt",
19020 ));
19021 }
19022 Ok(bytes)
19023 })();
19024 let failed = bytes.is_err();
19025 uploads.push(crate::round::PendingUpload {
19026 id: blob_id,
19027 bytes,
19028 media_type,
19029 });
19030 if failed {
19031 break;
19032 }
19033 }
19034 Ok(uploads)
19035 }
19036
19037 #[cfg(test)]
19038 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
19039 for upload in self.pending_blob_uploads()? {
19040 self.upload_one(
19041 transport,
19042 &upload.id,
19043 &upload.bytes?,
19044 upload.media_type.as_deref(),
19045 )?;
19046 self.conn
19047 .execute(
19048 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
19049 rusqlite::params![upload.id],
19050 )
19051 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19052 }
19053 Ok(())
19054 }
19055
19056 #[cfg(test)]
19063 fn upload_one(
19064 &self,
19065 transport: &mut dyn Transport,
19066 blob_id: &str,
19067 bytes: &[u8],
19068 media_type: Option<&str>,
19069 ) -> Result<(), TransportError> {
19070 crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
19071 }
19072
19073 fn visible_blob_ids_sql(&self) -> String {
19074 let mut selects = Vec::new();
19075 for table in &self.schema.tables {
19076 for column in table
19077 .columns
19078 .iter()
19079 .filter(|column| column.ty == ColumnType::BlobRef)
19080 {
19081 let identifier = quote_ident(&column.name);
19082 selects.push(format!(
19083 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
19084 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
19085 AND json_type({identifier}, '$.blobId') = 'text'",
19086 quote_ident(&table.name),
19087 ));
19088 }
19089 }
19090 if selects.is_empty() {
19091 "SELECT NULL AS blob_id WHERE 0".to_owned()
19092 } else {
19093 selects.join(" UNION ")
19094 }
19095 }
19096
19097 fn enforce_blob_cache_cap(&self) {
19100 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
19101 return;
19102 };
19103 let mut total: i64 = self
19104 .conn
19105 .query_row(
19106 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
19107 [],
19108 |row| row.get(0),
19109 )
19110 .unwrap_or(0);
19111 if total <= max_bytes {
19112 return;
19113 }
19114 let query = format!(
19115 "SELECT blob_id, byte_length FROM _syncular_blobs
19116 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
19117 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
19118 AND blob_id NOT IN ({})
19119 ORDER BY created_at_ms ASC, blob_id ASC",
19120 self.visible_blob_ids_sql(),
19121 );
19122 let candidates: Vec<(String, i64)> = {
19123 let Ok(mut stmt) = self.conn.prepare(&query) else {
19124 return;
19125 };
19126 let Ok(rows) = stmt.query_map([], |row| {
19127 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
19128 }) else {
19129 return;
19130 };
19131 rows.filter_map(Result::ok).collect()
19132 };
19133 for (blob_id, byte_length) in candidates {
19134 if total <= max_bytes {
19135 break;
19136 }
19137 let _ = self.conn.execute(
19138 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
19139 rusqlite::params![blob_id],
19140 );
19141 total -= byte_length;
19142 }
19143 }
19144
19145 fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
19146 #[cfg(feature = "bench-internals")]
19147 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
19148 if !self.schema_has_blobs() {
19149 return Ok(());
19150 }
19151 self.rebuild_overlay_if_dirty()?;
19152 self.conn
19153 .execute(
19154 &format!(
19155 "DELETE FROM _syncular_blobs
19156 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
19157 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
19158 AND blob_id NOT IN ({})",
19159 self.visible_blob_ids_sql(),
19160 ),
19161 [],
19162 )
19163 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19164 Ok(())
19165 }
19166
19167 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
19172 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
19173 return sql.clone();
19174 }
19175 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
19176 columns.push(quote_ident("_syncular_version"));
19177 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
19178 let primary_key = quote_ident(&table.primary_key);
19179 let updates = columns
19180 .iter()
19181 .filter(|column| **column != primary_key)
19182 .map(|column| format!("{column}=excluded.{column}"))
19183 .collect::<Vec<_>>()
19184 .join(", ");
19185 let sql = format!(
19186 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
19187 columns.join(", "),
19188 placeholders.join(", ")
19189 );
19190 self.insert_sql
19191 .borrow_mut()
19192 .insert(full_table.to_owned(), sql.clone());
19193 sql
19194 }
19195
19196 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
19197 self.overlay_dirty.table(table_name);
19198 self.write_row(&base_table(table_name), table_name, row, version)
19199 }
19200
19201 fn write_row(
19202 &self,
19203 full_table: &str,
19204 table_name: &str,
19205 row: &Row,
19206 version: i64,
19207 ) -> Result<(), String> {
19208 #[cfg(feature = "bench-internals")]
19209 let _phase = self.benchmark_phases.start(Phase::RowWrite);
19210 let table = self
19211 .schema
19212 .table(table_name)
19213 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
19214 let sql = self.insert_row_sql(full_table, table);
19215 let mut stmt = self
19216 .conn
19217 .prepare_cached(&sql)
19218 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19219 let params = row
19220 .iter()
19221 .map(RowParam::Cell)
19222 .chain(std::iter::once(RowParam::Version(version)));
19223 stmt.execute(rusqlite::params_from_iter(params))
19224 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19225 Ok(())
19226 }
19227
19228 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
19229 let table = self
19230 .schema
19231 .table(table_name)
19232 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
19233 self.overlay_dirty.table(table_name);
19234 let sql = format!(
19235 "DELETE FROM {} WHERE {}",
19236 base_table(table_name),
19237 row_id_predicate(table)
19238 );
19239 let mut stmt = self
19240 .conn
19241 .prepare_cached(&sql)
19242 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19243 stmt.execute(rusqlite::params![row_id])
19244 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19245 Ok(())
19246 }
19247
19248 fn has_acknowledged_rows(&self) -> bool {
19249 self.conn
19250 .query_row(
19251 "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
19252 [],
19253 |row| row.get(0),
19254 )
19255 .expect("read acknowledged intent")
19256 }
19257
19258 fn acknowledged_operations(
19259 &self,
19260 rows: Option<&HashSet<(&str, &str)>>,
19261 tables: Option<&BTreeSet<&str>>,
19262 ) -> Result<Vec<OutboxOp>, String> {
19263 let keys = rows.map(|rows| {
19264 serde_json::to_string(
19265 &rows
19266 .iter()
19267 .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
19268 .collect::<Vec<_>>(),
19269 )
19270 .expect("row keys")
19271 });
19272 let mut statement = self
19273 .conn
19274 .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")
19275 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19276 let rows = statement
19277 .query_map(
19278 rusqlite::params![
19279 keys,
19280 tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
19281 ],
19282 |row| {
19283 #[cfg(test)]
19284 self.acknowledged_replay_count
19285 .set(self.acknowledged_replay_count.get() + 1);
19286 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
19287 },
19288 )
19289 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19290 rows.map(|row| {
19291 let (op, intent) =
19292 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19293 let op: CommitOperation =
19294 serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
19295 let table = self
19296 .schema
19297 .table(&op.table)
19298 .ok_or_else(|| "sync.unknown_table".to_owned())?;
19299 let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
19300 Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
19301 } else {
19302 op.values
19303 };
19304 Ok(OutboxOp {
19305 table: op.table,
19306 row_id: op.row_id,
19307 upsert: op.op == "upsert",
19308 base_version: op.base_version,
19309 values,
19310 })
19311 })
19312 .collect()
19313 }
19314
19315 fn drop_acknowledged_rows(
19316 &self,
19317 matches: impl Fn(&str, &Map<String, Value>) -> bool,
19318 ) -> Result<Vec<OutboxCommit>, String> {
19319 let mut statement = self
19320 .conn
19321 .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
19322 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19323 let rows = statement
19324 .query_map([], |row| {
19325 Ok((
19326 row.get::<_, String>(0)?,
19327 row.get::<_, String>(1)?,
19328 row.get::<_, String>(2)?,
19329 row.get::<_, String>(3)?,
19330 ))
19331 })
19332 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19333 let mut doomed = HashSet::new();
19334 for row in rows {
19335 let (id, table, intent, row_id) =
19336 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19337 let values =
19338 serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
19339 let base_matches = self
19340 .schema
19341 .table(&table)
19342 .map(|schema| self.stored_row(schema, &row_id, true))
19343 .transpose()?
19344 .flatten()
19345 .is_some_and(|(base, _)| {
19346 let schema = self.schema.table(&table).expect("acknowledged table");
19347 let values = schema
19348 .columns
19349 .iter()
19350 .zip(base.iter())
19351 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
19352 .collect();
19353 matches(&table, &values)
19354 });
19355 if matches(&table, &values) || base_matches {
19356 doomed.insert(id);
19357 }
19358 }
19359 let mut dropped = Vec::new();
19360 for id in doomed {
19361 let mut statement = self
19362 .conn
19363 .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
19364 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19365 let operations = statement
19366 .query_map([&id], |row| row.get::<_, String>(0))
19367 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19368 let mut ops = Vec::new();
19369 for operation in operations {
19370 let json = operation
19371 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19372 let op: CommitOperation =
19373 serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
19374 self.overlay_dirty.table(&op.table);
19375 ops.push(OutboxOp {
19376 table: op.table,
19377 row_id: op.row_id,
19378 upsert: op.op == "upsert",
19379 base_version: op.base_version,
19380 values: op.values,
19381 });
19382 }
19383 dropped.push(OutboxCommit {
19384 client_commit_id: id.clone(),
19385 ops,
19386 });
19387 self.conn
19388 .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
19389 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19390 }
19391 Ok(dropped)
19392 }
19393
19394 fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
19397 if self.overlay_dirty.get() {
19398 self.rebuild_overlay()?;
19399 }
19400 Ok(())
19401 }
19402
19403 fn rebuild_overlay(&mut self) -> Result<(), String> {
19407 #[cfg(feature = "bench-internals")]
19408 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
19409 #[cfg(test)]
19410 self.overlay_rebuild_count
19411 .set(self.overlay_rebuild_count.get() + 1);
19412 let dirty = self.overlay_dirty.snapshot();
19413 let tables: Vec<_> = self
19414 .schema
19415 .tables
19416 .iter()
19417 .filter(|table| {
19418 dirty
19419 .as_ref()
19420 .is_none_or(|names| names.is_empty() || names.contains(&table.name))
19421 })
19422 .cloned()
19423 .collect();
19424 let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
19425 self.begin_observation("syncular_overlay")?;
19426 let rebuilt = (|| {
19427 for table in &tables {
19428 for index in &table.fts_indexes {
19429 self.drop_fts_triggers(index)?;
19430 }
19431 let visible = visible_table(&table.name);
19432 let base = base_table(&table.name);
19433 self.conn
19434 .execute_batch(&format!(
19435 "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
19436 ))
19437 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19438 }
19439 let acknowledged = self.acknowledged_operations(None, Some(&names))?;
19440 self.apply_outbox_ops(
19441 acknowledged
19442 .iter()
19443 .filter(|op| names.contains(op.table.as_str())),
19444 )?;
19445 for (commit, initial) in &self.failed_commits {
19446 for operation in &commit.ops {
19447 if !names.contains(operation.table.as_str()) || !operation.upsert {
19448 continue;
19449 }
19450 if let Some(table) = self.schema.table(&operation.table) {
19451 if self.visible_row(table, &operation.row_id)?.is_none()
19452 && operation.values.as_ref().is_some_and(|values| {
19453 table
19454 .columns
19455 .iter()
19456 .all(|column| values.contains_key(&column.name))
19457 })
19458 {
19459 let key = serde_json::to_string(&(
19460 operation.table.clone(),
19461 operation.row_id.clone(),
19462 ))
19463 .expect("row key serialization");
19464 if let Some(Value::Object(values)) = initial.get(&key) {
19465 self.apply_outbox_ops(&[OutboxOp {
19466 values: Some(values.clone()),
19467 ..operation.clone()
19468 }])?;
19469 }
19470 }
19471 }
19472 }
19473 self.apply_outbox_ops(
19474 commit
19475 .ops
19476 .iter()
19477 .filter(|op| names.contains(op.table.as_str())),
19478 )?;
19479 }
19480 for commit in &self.outbox {
19481 self.apply_outbox_ops(
19482 commit
19483 .ops
19484 .iter()
19485 .filter(|op| names.contains(op.table.as_str())),
19486 )?;
19487 }
19488 for table in &tables {
19489 for index in &table.fts_indexes {
19490 self.rebuild_fts_projection(table, index)?;
19491 self.create_fts_triggers(table, index)?;
19492 }
19493 }
19494 self.conn
19495 .execute_batch("RELEASE syncular_overlay")
19496 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19497 Ok(())
19498 })();
19499 if rebuilt.is_err() {
19500 self.conn
19501 .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
19502 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19503 } else {
19504 self.overlay_dirty.set(false);
19505 }
19506 rebuilt
19507 }
19508
19509 fn apply_outbox_ops<'a>(
19514 &self,
19515 ops: impl IntoIterator<Item = &'a OutboxOp>,
19516 ) -> Result<(), String> {
19517 #[cfg(feature = "bench-internals")]
19518 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
19519 for op in ops {
19520 #[cfg(feature = "bench-internals")]
19521 if let Some(phase) = &mut phase {
19522 phase.unit();
19523 }
19524 if op.upsert {
19525 if let (Some(table), Some(values)) = (
19526 self.schema
19527 .table(&op.table)
19528 .filter(|table| table.indexes.iter().any(|index| index.unique)),
19529 op.values.as_ref(),
19530 ) {
19531 let mut intended: Map<String, Value> = self
19532 .visible_row(table, &op.row_id)?
19533 .map(|(row, _)| {
19534 table
19535 .columns
19536 .iter()
19537 .zip(&row)
19538 .map(|(column, value)| {
19539 (column.name.clone(), column_value_to_json(value))
19540 })
19541 .collect()
19542 })
19543 .unwrap_or_default();
19544 intended.extend(values.clone());
19545 if !self.unique_conflicts(table, &intended, false)?.is_empty() {
19546 continue;
19547 }
19548 }
19549 }
19550 self.apply_outbox_op(op)?;
19551 }
19552 Ok(())
19553 }
19554
19555 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
19556 let Some(table) = self.schema.table(&op.table) else {
19557 return Ok(());
19558 };
19559 let visible = visible_table(&table.name);
19560 if !op.upsert {
19561 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
19562 self.conn
19563 .prepare_cached(&sql)
19564 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
19565 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19566 return Ok(());
19567 }
19568 let Some(values) = op.values.as_ref() else {
19569 return Ok(());
19570 };
19571 let full = table
19576 .columns
19577 .iter()
19578 .all(|column| values.contains_key(&column.name));
19579 let local = self.visible_row(table, &op.row_id)?;
19580 if local.is_none() && !full {
19581 return Ok(());
19582 }
19583 let mut row: Row = Vec::with_capacity(table.columns.len());
19584 for (index, column) in table.columns.iter().enumerate() {
19585 let incoming = match values.get(&column.name) {
19586 Some(raw) => json_to_column_value(
19587 if raw.get("$bytes").is_some()
19588 && table
19589 .encrypted_columns
19590 .iter()
19591 .any(|encrypted| encrypted.index == index)
19592 {
19593 &table.wire_columns[index]
19594 } else {
19595 column
19596 },
19597 Some(raw),
19598 ),
19599 None => Ok(local
19600 .as_ref()
19601 .and_then(|(values, _)| values.get(index).cloned())
19602 .flatten()),
19603 };
19604 row.push(incoming.map_err(|_| {
19605 "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
19606 })?);
19607 }
19608 let version = local.as_ref().map_or(-1, |(_, version)| *version);
19609 self.write_row(&visible, &table.name, &row, version)
19610 .map_err(|error| {
19611 if error.starts_with("UNIQUE constraint failed:") {
19612 "sync.constraint_violation: local write violates a unique constraint".to_owned()
19613 } else {
19614 error
19615 }
19616 })
19617 }
19618
19619 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
19622 self.stored_row(table, row_id, false)
19623 }
19624
19625 fn stored_row(
19626 &self,
19627 table: &TableSchema,
19628 row_id: &str,
19629 base: bool,
19630 ) -> Result<Option<(Row, i64)>, String> {
19631 let sql = format!(
19632 "SELECT * FROM {} WHERE {}",
19633 if base {
19634 base_table(&table.name)
19635 } else {
19636 visible_table(&table.name)
19637 },
19638 row_id_predicate(table)
19639 );
19640 let mut stmt = self
19641 .conn
19642 .prepare_cached(&sql)
19643 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19644 let mut rows = stmt
19645 .query(rusqlite::params![row_id])
19646 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19647 let Some(row) = rows
19648 .next()
19649 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19650 else {
19651 return Ok(None);
19652 };
19653 let mut values = Vec::with_capacity(table.columns.len());
19654 for (index, column) in table.columns.iter().enumerate() {
19655 let raw = row
19656 .get_ref(index)
19657 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19658 if let rusqlite::types::ValueRef::Blob(bytes) = raw {
19659 if table
19660 .encrypted_columns
19661 .iter()
19662 .any(|encrypted| encrypted.index == index)
19663 {
19664 values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
19665 continue;
19666 }
19667 }
19668 let json = sql_ref_to_json(column, raw);
19669 values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
19670 "sync.local_corrupt: persisted local row value is invalid".to_owned()
19671 })?);
19672 }
19673 let version: i64 = row
19674 .get(table.columns.len())
19675 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19676 Ok(Some((values, version)))
19677 }
19678
19679 #[must_use]
19683 pub fn realtime_policy(&self) -> RealtimePolicy {
19684 self.realtime_policy
19685 }
19686
19687 #[must_use]
19689 pub fn realtime_state(&self) -> RealtimeState {
19690 self.realtime_state
19691 }
19692
19693 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
19696 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
19697 return Err(
19698 "sync.invalid_request: disconnect realtime before changing the realtime policy"
19699 .to_owned(),
19700 );
19701 }
19702 self.realtime_policy = policy;
19703 match policy {
19704 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
19705 RealtimePolicy::Required | RealtimePolicy::Optional
19706 if self.realtime_state == RealtimeState::Disabled =>
19707 {
19708 self.set_realtime_state(RealtimeState::Disconnected, None);
19709 }
19710 RealtimePolicy::Required | RealtimePolicy::Optional => {}
19711 }
19712 Ok(())
19713 }
19714
19715 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
19718 self.realtime_state = state;
19719 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
19720 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
19721 self.realtime_reason_code = None;
19722 self.realtime_retry_delay_ms = None;
19723 }
19724 }
19725
19726 #[must_use]
19727 fn realtime_connected(&self) -> bool {
19728 self.realtime_state == RealtimeState::Connected
19729 }
19730
19731 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
19732 if !self.transport_enabled {
19733 return Err("sync.offline: transport gate is closed".into());
19734 }
19735 if self.realtime_policy == RealtimePolicy::Off {
19736 return Err(
19737 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
19738 );
19739 }
19740 if self.realtime_connected() {
19741 return Ok(());
19742 }
19743 self.realtime_state = RealtimeState::Connecting;
19744 match transport.realtime_connect_for_client(&self.client_id) {
19745 Ok(()) => {
19746 self.set_realtime_state(RealtimeState::Connected, None);
19747 Ok(())
19748 }
19749 Err(error) => {
19750 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
19751 Err(format!("{}: {}", error.code, error.message))
19752 }
19753 }
19754 }
19755
19756 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
19757 self.cancel_sync_round();
19758 if self.realtime_state == RealtimeState::Disconnected
19761 || self.realtime_state == RealtimeState::Disabled
19762 {
19763 return;
19764 }
19765 let _ = transport.realtime_close();
19766 self.set_realtime_state(
19767 if self.realtime_policy == RealtimePolicy::Off {
19768 RealtimeState::Disabled
19769 } else {
19770 RealtimeState::Disconnected
19771 },
19772 None,
19773 );
19774 self.presence.clear(); }
19776
19777 pub fn set_presence(
19783 &mut self,
19784 transport: &mut dyn Transport,
19785 scope_key: &str,
19786 doc: Option<&Value>,
19787 ) -> Result<(), String> {
19788 if !self.transport_enabled {
19789 return Err("sync.offline: transport gate is closed".into());
19790 }
19791 if !self.realtime_connected() {
19792 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
19793 }
19794 let text = encode_presence_publish(scope_key, doc);
19795 transport
19796 .realtime_send(&text)
19797 .map_err(|e| format!("{}: {}", e.code, e.message))
19798 }
19799
19800 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
19802 self.presence
19803 .get(scope_key)
19804 .map(|peers| peers.values().cloned().collect())
19805 .unwrap_or_default()
19806 }
19807
19808 fn apply_presence(
19810 &mut self,
19811 scope_key: String,
19812 kind: Option<PresenceKind>,
19813 actor_id: Option<String>,
19814 client_id: Option<String>,
19815 doc: Option<Value>,
19816 error: Option<String>,
19817 ) {
19818 if error.is_some() {
19821 return;
19822 }
19823 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
19824 return;
19825 };
19826 let peer_key = format!("{actor_id} {client_id}");
19827 match kind {
19828 PresenceKind::Leave => {
19829 if let Some(peers) = self.presence.get_mut(&scope_key) {
19830 peers.remove(&peer_key);
19831 if peers.is_empty() {
19832 self.presence.remove(&scope_key);
19833 }
19834 }
19835 }
19836 _ => {
19837 let doc = match doc {
19838 Some(Value::Object(_)) => doc.unwrap(),
19839 _ => return,
19840 };
19841 self.presence.entry(scope_key).or_default().insert(
19842 peer_key,
19843 PresencePeer {
19844 actor_id,
19845 client_id,
19846 doc,
19847 },
19848 );
19849 }
19850 }
19851 }
19852
19853 pub fn on_realtime_text(&mut self, text: &str) {
19855 if self.stopped || !self.transport_enabled || self.security_preflight {
19856 return;
19857 }
19858 match parse_control(text) {
19859 Ok(ControlMessage::Hello { requires_sync, .. }) => {
19860 if requires_sync {
19861 self.set_sync_needed(true, true);
19863 }
19864 }
19865 Ok(ControlMessage::Presence {
19866 scope_key,
19867 kind,
19868 actor_id,
19869 client_id,
19870 doc,
19871 error,
19872 ..
19873 }) => {
19874 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
19875 }
19876 Ok(ControlMessage::Wake { .. }) => {
19877 self.set_sync_needed(true, true);
19879 }
19880 _ => {}
19881 }
19882 }
19883
19884 pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
19888 let mut transport = crate::round::DownloadResults::default();
19889 self.on_realtime_binary(&mut transport, bytes);
19890 transport.controls
19891 }
19892
19893 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
19894 if self.stopped || !self.transport_enabled || self.security_preflight {
19895 return;
19896 }
19897 #[cfg(feature = "bench-internals")]
19898 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
19899 let message = match decode_message(bytes) {
19900 Ok(m) if m.msg_kind == MsgKind::Response => m,
19901 _ => {
19902 self.set_sync_needed(true, true);
19903 return;
19904 }
19905 };
19906 #[cfg(feature = "bench-internals")]
19907 drop(decode_phase);
19908 let mut frames = message.frames.into_iter();
19909 let mut applied_cursor: Option<i64> = None;
19910 let mut any_covered = false;
19911 let mut dropped = self.begin_coalesced_apply().is_err();
19912 while let Some(frame) = frames.next() {
19913 let Frame::SubStart {
19914 id,
19915 status,
19916 effective_scopes,
19917 ..
19918 } = frame
19919 else {
19920 continue;
19921 };
19922 let mut body = Vec::new();
19923 let mut next_cursor: Option<i64> = None;
19924 for inner in frames.by_ref() {
19925 match inner {
19926 Frame::SubEnd {
19927 next_cursor: nc, ..
19928 } => {
19929 next_cursor = Some(nc);
19930 break;
19931 }
19932 Frame::Unknown { .. } => {}
19933 other => body.push(other),
19934 }
19935 }
19936 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
19937 dropped = true;
19938 continue;
19939 };
19940 let sub = &self.subs[sub_index];
19941 if status != SubStatus::Active
19944 || sub.state != SubState::Active
19945 || sub.bootstrap_state.is_some()
19946 || !sub.synced_once
19947 {
19948 dropped = true;
19949 continue;
19950 }
19951 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
19952 any_covered = true;
19954 continue;
19955 }
19956 let previous_effective = self.subs[sub_index].effective.clone();
19957 let previous_cursor = self.subs[sub_index].cursor;
19958 self.subs[sub_index].effective = Some(effective_scopes);
19959 let mut failed = false;
19960 for inner in body {
19961 match inner {
19962 Frame::Commit {
19963 tables,
19964 changes,
19965 commit_seq,
19966 ..
19967 } => {
19968 if self
19969 .apply_commit_frame(&tables, &changes, Some(commit_seq))
19970 .is_err()
19971 {
19972 failed = true;
19973 break;
19974 }
19975 }
19976 _ => {
19977 failed = true;
19978 break;
19979 }
19980 }
19981 }
19982 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
19983 self.apply_sub_end(sub_index, cursor, None)
19984 .ok()
19985 .map(|()| cursor)
19986 });
19987 let Some(next_cursor) = committed else {
19988 self.subs[sub_index].effective = previous_effective;
19989 self.subs[sub_index].cursor = previous_cursor;
19990 dropped = true;
19991 continue;
19992 };
19993 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
19994 }
19995 if self.finish_coalesced_apply().is_err() {
19998 applied_cursor = None;
19999 dropped = true;
20000 }
20001 if let Some(cursor) = applied_cursor {
20002 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
20004 let _ = transport.realtime_send(&ack);
20005 } else if !any_covered || dropped {
20006 self.set_sync_needed(true, true);
20008 }
20009 }
20010
20011 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
20015 if !self.realtime_connected() {
20016 return;
20017 }
20018 let floor = self
20019 .subs
20020 .iter()
20021 .filter(|s| {
20022 s.state == SubState::Active
20023 && s.bootstrap_state.is_none()
20024 && s.synced_once
20025 && s.cursor >= 0
20026 })
20027 .map(|s| s.cursor)
20028 .min();
20029 if let Some(cursor) = floor {
20030 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
20031 let _ = transport.realtime_send(&ack);
20032 }
20033 }
20034}
20035
20036#[cfg(test)]
20037mod previous_version_wiring_tests {
20038 use super::*;
20039 use serde_json::{json, Value};
20040
20041 fn previous_version_schema(version: i32) -> Value {
20042 json!({
20043 "version": version,
20044 "tables": [{
20045 "name": "tasks",
20046 "primaryKey": "id",
20047 "columns": [
20048 { "name": "id", "type": "string", "nullable": false },
20049 { "name": "note", "type": "string", "nullable": true }
20050 ],
20051 "scopes": []
20052 }]
20053 })
20054 }
20055
20056 fn previous_version_limits() -> crate::api::ClientLimits {
20057 crate::api::ClientLimits {
20058 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
20059 enabled: true,
20060 ..Default::default()
20061 }),
20062 ..Default::default()
20063 }
20064 }
20065
20066 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
20068 previous_version_limits()
20069 .previous_version_context
20070 .expect("configured")
20071 }
20072
20073 fn recapture_previous_version(client: &SyncClient) {
20076 let replica_path = client
20077 .previous_version_replica_path()
20078 .expect("file-backed replica");
20079 let outcome = crate::previous_version::capture_previous_version_from_replica(
20080 &client.conn,
20081 &replica_path,
20082 2,
20083 2,
20084 &previous_version_config(),
20085 1,
20086 )
20087 .expect("capture");
20088 assert!(matches!(
20089 outcome,
20090 crate::previous_version::CaptureOutcome::Captured(_)
20091 ));
20092 }
20093
20094 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
20095 Subscription {
20096 id: "wiring-sub".to_owned(),
20097 table: "tasks".to_owned(),
20098 requested: Vec::new(),
20099 params: None,
20100 cursor,
20101 bootstrap_state: bootstrap_state.map(str::to_owned),
20102 state,
20103 reason_code: None,
20104 effective: None,
20105 synced_once: false,
20106 }
20107 }
20108
20109 #[test]
20110 fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
20111 let path =
20112 std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
20113 let path_str = path.to_str().expect("temp path");
20114 let schema = previous_version_schema(3);
20115 let mut older = previous_version_schema(2);
20116 older["tables"][0]["columns"] =
20117 json!([{ "name": "id", "type": "string", "nullable": false }]);
20118 let mut client = SyncClient::open_path(
20119 "downgrade".into(),
20120 &previous_version_schema(1),
20121 previous_version_limits(),
20122 path_str,
20123 )
20124 .expect("fresh");
20125 client
20126 .mutate(vec![Mutation::Upsert {
20127 table: "tasks".into(),
20128 values: Map::from_iter([
20129 ("id".into(), json!("accepted")),
20130 ("note".into(), json!("old")),
20131 ]),
20132 base_version: None,
20133 }])
20134 .expect("v1 write");
20135 client
20136 .recreate_with_schema(&schema)
20137 .expect("upgrade captures context");
20138 let commit = client
20139 .mutate(vec![Mutation::Upsert {
20140 table: "tasks".into(),
20141 values: Map::from_iter([
20142 ("id".into(), json!("queued")),
20143 ("note".into(), json!("v3-only")),
20144 ]),
20145 base_version: None,
20146 }])
20147 .expect("queued v3 write");
20148 let changes = client.conn.total_changes();
20149 let outbox = client.pending_commit_ids();
20150 assert!(outbox.contains(&commit));
20151 let descriptor = client.get_meta("localSchemaDescriptor");
20152 let container = format!(
20153 "{path_str}{}",
20154 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
20155 );
20156 let context = std::fs::read(&container).expect("captured context");
20157 assert!(client
20158 .recreate_with_schema(&older)
20159 .expect_err("refuse recreate")
20160 .starts_with("client.schema_downgrade:"));
20161 assert_eq!(client.schema.version, 3);
20162 assert_eq!(client.conn.total_changes(), changes);
20163 assert_eq!(client.pending_commit_ids(), outbox);
20164 assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
20165 assert_eq!(
20166 client
20167 .conn
20168 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
20169 row.get::<_, String>(0)
20170 })
20171 .expect("queued row"),
20172 "v3-only"
20173 );
20174 assert_eq!(std::fs::read(&container).expect("context remains"), context);
20175 drop(client);
20176 let before = std::fs::read(&path).expect("replica bytes");
20177 for entry in 0..3 {
20180 let error = match entry {
20181 0 => SyncClient::open_path(
20182 "downgrade".into(),
20183 &older,
20184 previous_version_limits(),
20185 path_str,
20186 ),
20187 1 => SyncClient::open_path_with_identity(
20188 None,
20189 &older,
20190 previous_version_limits(),
20191 path_str,
20192 ),
20193 _ => SyncClient::with_connection(
20194 "downgrade".into(),
20195 &older,
20196 previous_version_limits(),
20197 Connection::open(&path).expect("connection"),
20198 ),
20199 }
20200 .err()
20201 .expect("refuse open");
20202 assert!(error.starts_with("client.schema_downgrade:"));
20203 let conn = Connection::open(&path).expect("inspect");
20204 assert_eq!(
20205 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
20206 .expect("journal mode"),
20207 "delete"
20208 );
20209 drop(conn);
20210 assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
20211 assert_eq!(
20212 std::fs::read(&container).expect("context unchanged"),
20213 context
20214 );
20215 }
20216 let reopened = SyncClient::open_path(
20217 "downgrade".into(),
20218 &schema,
20219 previous_version_limits(),
20220 path_str,
20221 )
20222 .expect("compatible reopen");
20223 assert_eq!(reopened.pending_commit_ids(), outbox);
20224 assert_eq!(
20225 reopened
20226 .conn
20227 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
20228 row.get::<_, String>(0)
20229 })
20230 .expect("queued row remains"),
20231 "v3-only"
20232 );
20233 reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
20234 reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
20235 drop(reopened);
20236 let legacy = SyncClient::open_path(
20237 "downgrade".into(),
20238 &schema,
20239 previous_version_limits(),
20240 path_str,
20241 )
20242 .expect("legacy no-marker reopen");
20243 assert_eq!(legacy.pending_commit_ids(), outbox);
20244 assert!(!legacy.upgrading());
20245 assert_eq!(
20246 legacy
20247 .conn
20248 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
20249 row.get::<_, String>(0)
20250 })
20251 .expect("legacy row remains"),
20252 "v3-only"
20253 );
20254 drop(legacy);
20255 std::fs::remove_file(path).expect("remove replica");
20256 let _ = std::fs::remove_file(container);
20257 }
20258
20259 #[test]
20260 fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
20261 for marker in [
20262 "''",
20263 "'bad'",
20264 "'0'",
20265 "'-1'",
20266 "'2.5'",
20267 "'03'",
20268 "'3\n'",
20269 "' 3'",
20270 "'+3'",
20271 "'3e0'",
20272 "'2147483648'",
20273 "NULL",
20274 "X'33'",
20275 ] {
20276 let conn = Connection::open_in_memory().expect("connection");
20277 conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
20278 let error = SyncClient::with_connection(
20279 "marker".into(),
20280 &previous_version_schema(3),
20281 ClientLimits::default(),
20282 conn,
20283 )
20284 .err()
20285 .expect("refuse marker");
20286 assert!(
20287 error.starts_with("sync.local_corrupt:"),
20288 "{marker}: {error}"
20289 );
20290 }
20291 for fault in [
20292 "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
20293 "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
20294 ] {
20295 let mut client = SyncClient::new(
20296 "marker".into(),
20297 &previous_version_schema(3),
20298 ClientLimits::default(),
20299 )
20300 .expect("fresh");
20301 let commit = client
20302 .mutate(vec![Mutation::Upsert {
20303 table: "tasks".into(),
20304 values: Map::from_iter([("id".into(), json!("queued"))]),
20305 base_version: None,
20306 }])
20307 .expect("queued write");
20308 client.conn.execute_batch(fault).expect("marker fault");
20309 let changes = client.conn.total_changes();
20310 let error = client
20311 .recreate_with_schema(&previous_version_schema(4))
20312 .expect_err("refuse corrupt marker");
20313 assert!(error.starts_with("sync.local_corrupt:"));
20314 assert_eq!(client.conn.total_changes(), changes);
20315 assert_eq!(client.schema.version, 3);
20316 assert_eq!(client.pending_commit_ids(), vec![commit]);
20317 }
20318 for setup in [
20319 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
20320 "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
20321 ] {
20322 let path =
20323 std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
20324 let conn = Connection::open(&path).expect("connection");
20325 conn.execute_batch(setup)
20326 .expect("unreadable metadata fixture");
20327 drop(conn);
20328 let before = std::fs::read(&path).expect("before");
20329 let error = SyncClient::open_path_with_identity(
20330 None,
20331 &previous_version_schema(3),
20332 ClientLimits::default(),
20333 path.to_str().expect("path"),
20334 )
20335 .err()
20336 .expect("refuse unreadable marker");
20337 assert!(error.starts_with("sync.local_corrupt:"));
20338 assert_eq!(std::fs::read(&path).expect("after"), before);
20339 std::fs::remove_file(path).expect("remove fixture");
20340 }
20341 }
20342
20343 std::thread_local! {
20344 static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
20347 const { RefCell::new(None) };
20348 }
20349
20350 fn commit_concurrent_upgrade(attempts: i32) -> bool {
20351 CONCURRENT_UPGRADE.with(|slot| {
20352 if let Some(banner) = slot.borrow_mut().take() {
20353 banner
20354 .execute_batch(
20355 "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
20356 )
20357 .expect("commit the concurrent upgrade");
20358 }
20359 });
20360 attempts < 10
20361 }
20362
20363 fn journal_mode(conn: &Connection) -> String {
20364 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
20365 .expect("journal mode")
20366 }
20367
20368 fn temp_replica(label: &str) -> std::path::PathBuf {
20369 std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
20370 }
20371
20372 #[test]
20373 fn file_identity_constructor_requires_the_returned_wal_mode() {
20374 for path in [":memory:", ""] {
20375 let error = SyncClient::open_path_with_identity(
20376 None,
20377 &previous_version_schema(1),
20378 ClientLimits::default(),
20379 path,
20380 )
20381 .err()
20382 .expect("temporary SQLite databases cannot enter WAL mode");
20383 assert_eq!(
20384 error,
20385 "sync.invalid_request: file-backed client requires WAL journal mode"
20386 );
20387 }
20388 let path = temp_replica("confirmed-wal");
20389 let client = SyncClient::open_path_with_identity(
20390 None,
20391 &previous_version_schema(1),
20392 ClientLimits::default(),
20393 path.to_str().unwrap(),
20394 )
20395 .unwrap();
20396 assert_eq!(journal_mode(&client.conn), "wal");
20397 drop(client);
20398 std::fs::remove_file(path).unwrap();
20399 }
20400
20401 #[test]
20402 fn non_positive_generated_schema_versions_are_refused_before_storage() {
20403 for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
20404 let mut schema = previous_version_schema(1);
20405 schema["version"] = version.clone();
20406 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
20407 .err()
20408 .expect("refuse an out-of-range generated version");
20409 assert_eq!(
20410 error,
20411 "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
20412 );
20413 let path = temp_replica("bad-version");
20415 let path_str = path.to_str().expect("path");
20416 for identity in [false, true] {
20417 let error = if identity {
20418 SyncClient::open_path_with_identity(
20419 None,
20420 &schema,
20421 ClientLimits::default(),
20422 path_str,
20423 )
20424 } else {
20425 SyncClient::open_path(
20426 "bad-version".into(),
20427 &schema,
20428 ClientLimits::default(),
20429 path_str,
20430 )
20431 }
20432 .err()
20433 .expect("refuse before storage");
20434 assert!(
20435 error.starts_with("sync.invalid_request:"),
20436 "{version}: {error}"
20437 );
20438 assert!(
20439 !path.exists(),
20440 "{version}: a refused constructor creates no replica file"
20441 );
20442 }
20443 }
20444 let mut schema = previous_version_schema(1);
20446 schema
20447 .as_object_mut()
20448 .expect("schema object")
20449 .remove("version");
20450 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
20451 .err()
20452 .expect("refuse a missing version");
20453 assert!(error.starts_with("sync.invalid_request:"), "{error}");
20454 }
20455
20456 #[test]
20457 fn a_duplicated_schema_marker_is_refused_without_a_reset() {
20458 let path = temp_replica("duplicate-marker");
20459 let path_str = path.to_str().expect("path");
20460 {
20461 let conn = Connection::open(path_str).expect("fixture");
20464 conn.execute_batch(
20465 "CREATE TABLE _syncular_meta(key TEXT, value);
20466 INSERT INTO _syncular_meta VALUES
20467 ('localSchemaVersion', '1'),
20468 ('localSchemaVersion', '2'),
20469 ('clientId', 'duplicate');",
20470 )
20471 .expect("duplicate marker fixture");
20472 }
20473 let error = SyncClient::open_path_with_identity(
20474 None,
20475 &previous_version_schema(3),
20476 ClientLimits::default(),
20477 path_str,
20478 )
20479 .err()
20480 .expect("refuse a duplicated marker");
20481 assert!(error.starts_with("sync.local_corrupt:"), "{error}");
20482 let probe = Connection::open(path_str).expect("probe");
20483 assert_eq!(
20484 probe
20485 .query_row(
20486 "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
20487 [],
20488 |row| row.get::<_, i64>(0),
20489 )
20490 .expect("marker rows"),
20491 2,
20492 "the duplicated rows are left for the operator, not collapsed"
20493 );
20494 assert_eq!(
20495 probe
20496 .query_row(
20497 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
20498 [],
20499 |row| row.get::<_, i64>(0),
20500 )
20501 .expect("synced tables"),
20502 0,
20503 "a duplicated marker never drives the reset that recreates v3 tables"
20504 );
20505 assert_eq!(
20506 journal_mode(&probe),
20507 "delete",
20508 "the refusal leaves the journal mode alone"
20509 );
20510 drop(probe);
20511 std::fs::remove_file(path).expect("remove replica");
20512 }
20513
20514 #[test]
20515 fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
20516 let path = temp_replica("refused-wal");
20517 let path_str = path.to_str().expect("path");
20518 drop(
20521 SyncClient::open_path(
20522 "owner".into(),
20523 &previous_version_schema(2),
20524 ClientLimits::default(),
20525 path_str,
20526 )
20527 .expect("v2 replica"),
20528 );
20529 let before = std::fs::read(&path).expect("before");
20530 let error = SyncClient::open_path_with_identity(
20531 Some("intruder".into()),
20532 &previous_version_schema(2),
20533 ClientLimits::default(),
20534 path_str,
20535 )
20536 .err()
20537 .expect("refuse the identity mismatch");
20538 assert!(error.starts_with("client.identity_mismatch:"), "{error}");
20539 let probe = Connection::open(path_str).expect("probe");
20540 assert_eq!(
20541 journal_mode(&probe),
20542 "delete",
20543 "a refused open must not persist WAL"
20544 );
20545 assert_eq!(std::fs::read(&path).expect("after"), before);
20546 assert_eq!(
20547 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20548 Some("2")
20549 );
20550 drop(probe);
20551 std::fs::remove_file(path).expect("remove replica");
20552 }
20553
20554 #[test]
20555 fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
20556 let path = temp_replica("marker-race");
20557 let path_str = path.to_str().expect("path");
20558 drop(
20559 SyncClient::open_path_with_identity(
20560 Some("stale".into()),
20561 &previous_version_schema(1),
20562 ClientLimits::default(),
20563 path_str,
20564 )
20565 .expect("v1 replica"),
20566 );
20567 let banner = Connection::open(path_str).expect("banner");
20571 banner
20572 .execute_batch(
20573 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
20574 )
20575 .expect("pending upgrade");
20576 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
20577 let conn = Connection::open(path_str).expect("stale connection");
20578 conn.busy_handler(Some(commit_concurrent_upgrade))
20579 .expect("busy handler");
20580 let error = SyncClient::with_connection(
20581 "stale".into(),
20582 &previous_version_schema(1),
20583 ClientLimits::default(),
20584 conn,
20585 )
20586 .err()
20587 .expect("a concurrent upgrade must refuse the stale open");
20588 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
20589 let probe = Connection::open(path_str).expect("probe");
20590 assert_eq!(
20591 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20592 Some("2"),
20593 "the concurrent upgrade survives"
20594 );
20595 drop(probe);
20596 std::fs::remove_file(path).expect("remove replica");
20597 }
20598
20599 #[test]
20600 fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
20601 let path = temp_replica("recreate-race");
20602 let path_str = path.to_str().expect("path");
20603 let mut client = SyncClient::open_path_with_identity(
20604 Some("stale".into()),
20605 &previous_version_schema(1),
20606 ClientLimits::default(),
20607 path_str,
20608 )
20609 .expect("v1 replica");
20610 client
20611 .mutate(vec![Mutation::Upsert {
20612 table: "tasks".into(),
20613 values: Map::from_iter([("id".into(), json!("queued"))]),
20614 base_version: None,
20615 }])
20616 .expect("queued write");
20617 let commit = client.pending_commit_ids();
20618 let banner = Connection::open(path_str).expect("banner");
20619 banner
20620 .execute_batch(
20621 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
20622 )
20623 .expect("pending upgrade");
20624 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
20625 client
20626 .conn
20627 .busy_handler(Some(commit_concurrent_upgrade))
20628 .expect("busy handler");
20629 let error = client
20630 .recreate_with_schema(&previous_version_schema(1))
20631 .expect_err("a concurrent upgrade must refuse the stale recreate");
20632 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
20633 assert_eq!(client.schema.version, 1, "the compiled schema is restored");
20634 assert_eq!(
20635 client.pending_commit_ids(),
20636 commit,
20637 "the outbox is preserved"
20638 );
20639 let probe = Connection::open(path_str).expect("probe");
20640 assert_eq!(
20641 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20642 Some("2")
20643 );
20644 drop(probe);
20645 drop(client);
20646 std::fs::remove_file(path).expect("remove replica");
20647 }
20648
20649 #[test]
20650 fn failed_schema_record_writes_roll_back_recreation() {
20651 for key in [
20652 LOCAL_SCHEMA_VERSION_KEY,
20653 crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
20654 ] {
20655 let mut client = SyncClient::new(
20656 "schema-record".into(),
20657 &previous_version_schema(1),
20658 ClientLimits::default(),
20659 )
20660 .expect("v1");
20661 let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
20662 let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
20663 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");
20664 client
20665 .recreate_with_schema(&previous_version_schema(2))
20666 .expect_err("both record writes must propagate failure");
20667 assert_eq!(client.schema.version, 1);
20668 assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
20669 assert_eq!(
20670 client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
20671 descriptor
20672 );
20673 client
20674 .conn
20675 .execute_batch("DROP TRIGGER refuse_schema_record")
20676 .expect("remove fault");
20677 client
20678 .recreate_with_schema(&previous_version_schema(2))
20679 .expect("retry");
20680 assert_eq!(client.schema.version, 2);
20681 }
20682 }
20683
20684 #[test]
20685 fn a_failed_outer_commit_restores_every_reset_side_effect() {
20686 let path = temp_replica("outer-commit");
20687 let path_str = path.to_str().expect("path");
20688 let conn = Connection::open(path_str).expect("replica connection");
20691 let mut client = SyncClient::with_connection(
20692 "outer-commit".into(),
20693 &previous_version_schema(1),
20694 previous_version_limits(),
20695 conn,
20696 )
20697 .expect("v1 replica");
20698 assert_eq!(journal_mode(&client.conn), "delete");
20699 let commit = client
20700 .mutate(vec![Mutation::Upsert {
20701 table: "tasks".into(),
20702 values: Map::from_iter([
20703 ("id".into(), json!("queued")),
20704 ("note".into(), json!("dropped-by-v2")),
20705 ]),
20706 base_version: None,
20707 }])
20708 .expect("queued write");
20709 client.drain_change_batches();
20710 let mut dropped = previous_version_schema(2);
20712 dropped["tables"][0]["columns"] =
20713 json!([{ "name": "id", "type": "string", "nullable": false }]);
20714 let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
20717 let revision = client.local_revision();
20718 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
20719 let columns: i64 = client
20720 .conn
20721 .query_row(
20722 "SELECT count(*) FROM pragma_table_info('tasks')",
20723 [],
20724 |row| row.get(0),
20725 )
20726 .expect("column count");
20727 let rebuilds = client.overlay_rebuild_count.get();
20728 let prior_subs = client.subs.len();
20729 let prior_changes = client.change_queue.len();
20730 let prior_intents = client.sync_intent_queue.len();
20731 let prior_last_change = client.last_change.clone();
20732 let prior_overlay_dirty = client.overlay_dirty.get();
20733 let prior_sync_needed = client.sync_needed;
20734 let prior_upgrading = client.upgrading;
20735 let prior_stopped = client.stopped;
20736 let prior_schema_floor = client.schema_floor.clone();
20737 let prior_active_round = client.active_round;
20738 let reader = Connection::open(path_str).expect("reader");
20741 reader
20742 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
20743 .expect("reader snapshot");
20744 client
20745 .conn
20746 .busy_timeout(std::time::Duration::ZERO)
20747 .expect("busy timeout");
20748 let error = client
20749 .recreate_with_schema(&dropped)
20750 .expect_err("a blocked COMMIT must refuse the recreation");
20751 assert!(error.starts_with("client.storage_busy:"), "{error}");
20752 assert!(
20753 client.overlay_rebuild_count.get() > rebuilds,
20754 "the reset ran to its overlay rebuild inside the transaction"
20755 );
20756 assert!(client.conn.is_autocommit(), "the transaction is released");
20757 assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
20759 assert_eq!(client.local_revision(), revision);
20760 assert_eq!(
20761 client.query("SELECT * FROM tasks", &[]).expect("rows"),
20762 rows
20763 );
20764 assert_eq!(
20765 client
20766 .conn
20767 .query_row(
20768 "SELECT count(*) FROM pragma_table_info('tasks')",
20769 [],
20770 |row| { row.get::<_, i64>(0) }
20771 )
20772 .expect("column count"),
20773 columns,
20774 "the previous table layout survives the rollback"
20775 );
20776 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
20777 assert_eq!(client.schema.version, 1, "compiled schema");
20780 assert_eq!(client.subs.len(), prior_subs);
20781 assert_eq!(client.change_queue.len(), prior_changes);
20782 assert_eq!(client.sync_intent_queue.len(), prior_intents);
20783 assert_eq!(client.last_change, prior_last_change);
20784 assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
20785 assert_eq!(client.sync_needed, prior_sync_needed);
20786 assert_eq!(client.upgrading, prior_upgrading);
20787 assert_eq!(client.stopped, prior_stopped);
20788 assert_eq!(client.schema_floor, prior_schema_floor);
20789 assert_eq!(client.active_round, prior_active_round);
20790 assert!(
20791 client.rejections.is_empty(),
20792 "the dropped-column rejection is rolled back too"
20793 );
20794 let container = crate::previous_version::previous_version_container_path(path_str);
20797 assert!(std::path::Path::new(&container).exists());
20798 drop(reader);
20801 let snapshot = client
20802 .previous_version_snapshot(&PreviousVersionReadSpec {
20803 table: "tasks".into(),
20804 ..Default::default()
20805 })
20806 .expect("refuse stale capture");
20807 assert!(!snapshot.available);
20808 assert_eq!(snapshot.current_version, 1);
20809 assert!(!std::path::Path::new(&container).exists());
20810 client
20815 .conn
20816 .busy_timeout(std::time::Duration::from_secs(1))
20817 .expect("busy timeout");
20818 client
20819 .recreate_with_schema(&dropped)
20820 .expect("the recreation succeeds once the reader releases");
20821 assert_eq!(client.schema.version, 2);
20822 assert_eq!(
20823 client.pending_commit_ids(),
20824 vec![commit.clone()],
20825 "the reset kept the incompatible commit pending"
20826 );
20827 assert!(
20828 client.rejections.is_empty(),
20829 "no rejection exists before the send-time prepass"
20830 );
20831 client
20833 .prepare_sync_round(false)
20834 .expect("the send prepass runs during round preparation");
20835 assert!(client.pending_commit_ids().is_empty());
20836 assert!(
20837 client
20838 .rejections
20839 .iter()
20840 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
20841 "the dropped-column commit surfaced as a rejection at send time"
20842 );
20843 client.previous_version_discard();
20844 drop(client);
20845 std::fs::remove_file(path).expect("remove replica");
20846 }
20847
20848 #[test]
20849 fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
20850 let v1 = previous_version_schema(1);
20851 let mut v2 = previous_version_schema(2);
20852 v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
20854 let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
20855 let dropped = client
20856 .mutate(vec![Mutation::Upsert {
20857 table: "tasks".into(),
20858 values: Map::from_iter([
20859 ("id".into(), json!("dropped")),
20860 ("note".into(), json!("gone")),
20861 ]),
20862 base_version: None,
20863 }])
20864 .unwrap();
20865 let survivor = client
20869 .mutate(vec![Mutation::Delete {
20870 table: "tasks".into(),
20871 row_id: "gone".into(),
20872 base_version: None,
20873 }])
20874 .unwrap();
20875 client
20876 .recreate_with_schema(&v2)
20877 .expect("the reset preserves the outbox");
20878 assert_eq!(
20879 client.pending_commit_ids(),
20880 vec![dropped.clone(), survivor.clone()],
20881 "the reset preserves both commits"
20882 );
20883 assert!(client.rejections.is_empty(), "the reset classifies nothing");
20884 client.drain_change_batches();
20885 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
20886 assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
20887
20888 for fault in [
20894 "outcome-insert",
20895 "outbox-delete",
20896 "overlay-rebuild",
20897 "publish-revision",
20898 ] {
20899 client.set_sync_needed(false, false);
20900 client.drain_change_batches();
20901 let revision = client.local_revision();
20902 let trigger = match fault {
20903 "outcome-insert" => {
20904 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20905 }
20906 "outbox-delete" => {
20907 "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20908 }
20909 "publish-revision" => {
20914 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20915 }
20916 _ => {
20917 "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20918 }
20919 };
20920 client.conn.execute_batch(trigger).unwrap();
20921 let outcome = client
20922 .prepare_sync_round(false)
20923 .err()
20924 .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
20925 assert!(
20926 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
20927 "{fault}: {outcome:?}"
20928 );
20929 client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
20930 assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
20931 assert_eq!(
20932 client.pending_commit_ids(),
20933 vec![dropped.clone(), survivor.clone()],
20934 "{fault}: the outbox is restored"
20935 );
20936 assert!(
20937 client.rejections.is_empty(),
20938 "{fault}: no in-memory rejection survives"
20939 );
20940 assert!(
20941 client.commit_outcome(&dropped).unwrap().is_none(),
20942 "{fault}: the durable outcome rolled back"
20943 );
20944 assert_eq!(client.local_revision(), revision, "{fault}: revision");
20945 assert_eq!(
20946 client.query("SELECT * FROM tasks", &[]).unwrap(),
20947 rows,
20948 "{fault}: the visible projection is unchanged"
20949 );
20950 assert!(
20951 client.drain_change_batches().is_empty(),
20952 "{fault}: nothing is published"
20953 );
20954 }
20955
20956 client
20959 .conn
20960 .execute_batch(
20961 "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
20962 CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
20963 BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
20964 )
20965 .unwrap();
20966 assert!(client.drop_incompatible_outbox().is_err());
20967 assert!(!client.conn.is_autocommit());
20968 assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
20969 assert_eq!(
20970 client.pending_commit_ids(),
20971 vec![dropped.clone(), survivor.clone()]
20972 );
20973 assert!(client.commit_outcome(&dropped).unwrap().is_none());
20974 client
20975 .conn
20976 .execute_batch("DROP TRIGGER fail_drop; COMMIT")
20977 .unwrap();
20978
20979 client.set_meta(LOG_EPOCH_KEY, "epoch");
20984 client.set_sync_needed(false, false);
20985 client.drain_change_batches();
20986 let revision = client.local_revision();
20987 let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
20988 assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
20989 assert_eq!(
20990 prepared.meta.pushed_ids,
20991 vec![survivor.clone()],
20992 "the incompatible commit is classified before any operation is sent"
20993 );
20994 let rejection = client
20995 .rejections
20996 .iter()
20997 .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
20998 .expect("a sync.outbox_incompatible rejection");
20999 assert_eq!(rejection.client_commit_id, dropped);
21000 assert!(
21001 !rejection.retryable,
21002 "a schema-incompatible commit is final"
21003 );
21004 assert_eq!(
21005 client
21006 .commit_outcome(&dropped)
21007 .unwrap()
21008 .expect("a durable rejected outcome")
21009 .status,
21010 CommitOutcomeStatus::Rejected
21011 );
21012 assert_eq!(client.local_revision(), revision + 1, "one revision");
21013 let batches = client.drain_change_batches();
21014 assert_eq!(batches.len(), 1, "one change batch");
21015 assert!(
21016 batches[0].rejections_changed
21017 && batches[0].outcomes_changed
21018 && batches[0].status.is_some(),
21019 "{:?}",
21020 batches[0]
21021 );
21022 assert!(
21023 batches[0].tables.iter().any(|entry| entry.table == "tasks"),
21024 "{:?}",
21025 batches[0]
21026 );
21027 let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
21028 assert!(
21029 visible.is_empty(),
21030 "the dropped projection is undone: {visible:?}"
21031 );
21032
21033 let path = temp_replica("outbox-drop-commit");
21037 let path_str = path.to_str().expect("path");
21038 let conn = Connection::open(path_str).expect("replica connection");
21039 let mut client =
21040 SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
21041 .expect("v1 replica");
21042 let dropped = client
21043 .mutate(vec![Mutation::Upsert {
21044 table: "tasks".into(),
21045 values: Map::from_iter([
21046 ("id".into(), json!("dropped")),
21047 ("note".into(), json!("gone")),
21048 ]),
21049 base_version: None,
21050 }])
21051 .unwrap();
21052 client
21053 .recreate_with_schema(&v2)
21054 .expect("the reset preserves the outbox");
21055 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
21056 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
21057 assert_eq!(rows.len(), 1, "the optimistic row is visible");
21058 client.set_sync_needed(false, false);
21059 client.drain_change_batches();
21060 let revision = client.local_revision();
21061 let reader = Connection::open(path_str).expect("reader");
21062 reader
21063 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
21064 .expect("reader snapshot");
21065 client
21066 .conn
21067 .busy_timeout(std::time::Duration::ZERO)
21068 .expect("busy timeout");
21069 let outcome = client
21070 .prepare_sync_round(false)
21071 .err()
21072 .expect("a blocked COMMIT must fail the round");
21073 assert!(
21074 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
21075 "{outcome:?}"
21076 );
21077 assert!(client.conn.is_autocommit(), "the savepoint is released");
21078 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
21079 assert!(client.rejections.is_empty());
21080 assert!(client.commit_outcome(&dropped).unwrap().is_none());
21081 assert_eq!(client.local_revision(), revision);
21082 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
21083 assert!(client.drain_change_batches().is_empty());
21084 drop(reader);
21086 client
21087 .conn
21088 .busy_timeout(std::time::Duration::from_secs(1))
21089 .expect("busy timeout");
21090 client
21091 .prepare_sync_round(false)
21092 .expect("the drop succeeds after the reader releases");
21093 assert!(client.pending_commit_ids().is_empty());
21094 assert!(client
21095 .rejections
21096 .iter()
21097 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
21098 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
21099 drop(client);
21100 std::fs::remove_file(path).expect("remove replica");
21101 }
21102
21103 #[test]
21104 fn previous_version_read_reasons_come_from_live_client_state() {
21105 use crate::previous_version::{
21106 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
21107 };
21108 let path = std::env::temp_dir()
21109 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
21110 .to_string_lossy()
21111 .into_owned();
21112 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
21113 {
21114 let mut setup = SyncClient::open_path(
21115 "wiring".to_owned(),
21116 &previous_version_schema(1),
21117 previous_version_limits(),
21118 &path,
21119 )
21120 .expect("v1 install");
21121 setup
21122 .mutate(vec![Mutation::Upsert {
21123 table: "tasks".to_owned(),
21124 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
21125 base_version: None,
21126 }])
21127 .expect("seed a row");
21128 }
21129 let mut client = SyncClient::open_path(
21130 "wiring".to_owned(),
21131 &previous_version_schema(2),
21132 previous_version_limits(),
21133 &path,
21134 )
21135 .expect("v2 bump captures");
21136 assert!(std::path::Path::new(&container).exists());
21137 let spec = PreviousVersionReadSpec {
21138 table: "tasks".to_owned(),
21139 ..Default::default()
21140 };
21141
21142 recapture_previous_version(&client);
21144 client.lease_state = Some(LeaseState {
21145 lease_id: None,
21146 expires_at_ms: None,
21147 error_code: Some("lease.expired".to_owned()),
21148 });
21149 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21150 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
21151 assert!(!snapshot.available);
21152 assert!(!std::path::Path::new(&container).exists());
21153
21154 recapture_previous_version(&client);
21156 client.set_now_ms(1_000);
21157 client.lease_state = Some(LeaseState {
21158 lease_id: Some("lease".to_owned()),
21159 expires_at_ms: Some(1_000),
21160 error_code: None,
21161 });
21162 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21163 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
21164 assert!(!std::path::Path::new(&container).exists());
21165 client.lease_state = None;
21166
21167 recapture_previous_version(&client);
21169 client.subs.push(subscription(SubState::Revoked, 0, None));
21170 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21171 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
21172 assert!(!std::path::Path::new(&container).exists());
21173 client.subs.clear();
21174
21175 recapture_previous_version(&client);
21178 client
21179 .subs
21180 .push(subscription(SubState::Active, 0, Some("resume")));
21181 assert!(!client.previous_version_coverage_complete());
21182 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21183 assert!(snapshot.available);
21184 assert_eq!(snapshot.previous_version, Some(2));
21185 assert_eq!(snapshot.rows.len(), 1);
21186
21187 recapture_previous_version(&client);
21188 client.subs[0].bootstrap_state = None;
21189 assert!(client.previous_version_coverage_complete());
21190 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21191 assert_eq!(
21192 snapshot.reason,
21193 Some(PreviousVersionReason::CoverageComplete)
21194 );
21195 assert!(!std::path::Path::new(&container).exists());
21196 client.subs.clear();
21197
21198 assert!(!client.previous_version_coverage_complete());
21200
21201 drop(client);
21202 let _ = std::fs::remove_file(&path);
21203 }
21204
21205 #[test]
21212 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
21213 let path = std::env::temp_dir()
21214 .join(format!(
21215 "syncular-prev-lifetime-{}.db",
21216 uuid::Uuid::new_v4()
21217 ))
21218 .to_string_lossy()
21219 .into_owned();
21220 let container = format!(
21221 "{path}{}",
21222 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
21223 );
21224 let exists = || std::path::Path::new(&container).exists();
21225 {
21226 let mut setup = SyncClient::open_path(
21227 "lifetime".to_owned(),
21228 &previous_version_schema(1),
21229 previous_version_limits(),
21230 &path,
21231 )
21232 .expect("v1 install");
21233 setup
21234 .mutate(vec![Mutation::Upsert {
21235 table: "tasks".to_owned(),
21236 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
21237 base_version: None,
21238 }])
21239 .expect("seed a row");
21240 }
21241 let mut client = SyncClient::open_path(
21242 "lifetime".to_owned(),
21243 &previous_version_schema(2),
21244 previous_version_limits(),
21245 &path,
21246 )
21247 .expect("v2 bump captures");
21248 client.set_now_ms(1_000);
21251 assert!(exists());
21252
21253 client.lease_state = Some(LeaseState {
21255 lease_id: None,
21256 expires_at_ms: None,
21257 error_code: Some("lease.expired".to_owned()),
21258 });
21259 client.previous_version_lifetime_check();
21260 assert!(!exists());
21261 client.lease_state = None;
21262
21263 recapture_previous_version(&client);
21266 client.subs.push(subscription(SubState::Active, 0, None));
21267 client.previous_version_lifetime_check();
21268 assert!(!exists());
21269 client.subs.clear();
21270
21271 recapture_previous_version(&client);
21273 client.subs.push(subscription(SubState::Revoked, 0, None));
21274 client.previous_version_lifetime_check();
21275 assert!(!exists());
21276 client.subs.clear();
21277
21278 recapture_previous_version(&client);
21281 client
21282 .subs
21283 .push(subscription(SubState::Active, 0, Some("resume")));
21284 client.previous_version_lifetime_check();
21285 assert!(exists());
21286 client.subs.clear();
21287
21288 drop(client);
21289 let _ = std::fs::remove_file(&path);
21290 let _ = std::fs::remove_file(&container);
21291 let _ = std::fs::remove_file(format!("{container}-wal"));
21292 let _ = std::fs::remove_file(format!("{container}-shm"));
21293 }
21294}