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 SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot, TableChange,
41 WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
42 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 #[cfg(feature = "e2ee")]
287 #[test]
288 fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
289 let schema = json!({
290 "version": 1,
291 "tables": [{
292 "name": "secrets",
293 "primaryKey": "id",
294 "columns": [
295 { "name": "id", "type": "string", "nullable": false },
296 { "name": "encryption_key_id", "type": "string", "nullable": true },
297 { "name": "note", "type": "bytes", "nullable": true,
298 "encrypted": true, "declaredType": "string" }
299 ],
300 "scopes": []
301 }]
302 });
303 let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
304 .expect("test client");
305 let mut config = crate::values::EncryptionConfig::default();
306 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
307 config
308 .key_id_columns
309 .insert("secrets".to_owned(), "encryption_key_id".to_owned());
310 client.set_encryption(config);
311 client
314 .write_base_row(
315 "secrets",
316 &vec![Some(ColumnValue::String("ghost".into())), None, None],
317 1,
318 )
319 .unwrap();
320 client.rebuild_overlay().expect("rebuild overlay");
321 let commit_id = client
322 .patch(
323 "secrets",
324 "ghost",
325 Map::from_iter([("note".to_owned(), json!("x"))]),
326 None,
327 )
328 .expect("patch records without an author-time encrypt failure");
329 assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
330 assert!(client.drop_unencodable_outbox(true, None).unwrap());
332 assert!(client.pending_commit_ids().is_empty());
333 let rejections = client.rejections();
334 assert_eq!(rejections.len(), 1);
335 assert_eq!(rejections[0].code, "client.encrypt_failed");
336 assert_eq!(rejections[0].client_commit_id, commit_id);
337 assert_eq!(
338 client
339 .commit_outcome(&commit_id)
340 .unwrap()
341 .map(|outcome| outcome.status),
342 Some(CommitOutcomeStatus::Rejected)
343 );
344 }
345
346 #[test]
347 fn patch_scope_read_preserves_sqlite_failure() {
348 let mut client = client();
349 client
350 .conn
351 .execute_batch(
352 "DROP TABLE tasks;
353 CREATE VIEW tasks AS SELECT 't1' AS id, abs(-9223372036854775808) AS project_id;",
354 )
355 .unwrap();
356 let error = client
357 .patch(
358 "tasks",
359 "t1",
360 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
361 None,
362 )
363 .expect_err("the scope read must fail");
364 assert_ne!(error.code, "sync.invalid_request");
365 assert_eq!(error.code, "client.failed");
366 assert!(error.details.as_ref().unwrap()["legacyCause"]
367 .as_str()
368 .unwrap()
369 .contains("integer overflow"));
370 assert!(client.pending_commit_ids().is_empty());
371 assert!(client.conn.is_autocommit());
372 }
373
374 #[test]
375 fn patch_with_a_scope_column_matches_the_stored_local_row() {
376 let mut client = client();
377 client.create_synced_tables().unwrap();
378 client
379 .conn
380 .execute(
381 "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
382 [],
383 )
384 .unwrap();
385
386 client
389 .patch(
390 "tasks",
391 "t1",
392 Map::from_iter([
393 ("project_id".to_owned(), json!("p1")),
394 ("id".to_owned(), json!("t1")),
395 ]),
396 None,
397 )
398 .expect("an equal scope column is a no-op");
399 let values = client.outbox[0].ops[0]
400 .values
401 .clone()
402 .expect("the patch records values");
403 assert_eq!(values.get("id"), Some(&json!("t1")));
404 assert!(!values.contains_key("project_id"));
405
406 let differing = client.patch(
408 "tasks",
409 "t1",
410 Map::from_iter([("project_id".to_owned(), json!("p2"))]),
411 None,
412 );
413 let differing = differing.expect_err("a differing scope column is rejected");
414 assert_eq!(differing.code, "sync.invalid_request");
415 assert_eq!(differing.message, "the authoring request is invalid");
416 assert!(
417 differing
418 .details
419 .as_ref()
420 .and_then(|details| details["legacyCause"].as_str())
421 .is_some_and(|cause| cause.contains("patch cannot write scope column")),
422 "{differing:?}"
423 );
424
425 let absent = client.patch(
428 "tasks",
429 "ghost",
430 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
431 None,
432 );
433 let absent = absent.expect_err("an absent local row is rejected");
434 assert_eq!(absent.code, "sync.row_missing");
435 assert!(absent.message.contains("requires a local row"));
436 assert_eq!(client.pending_commit_ids().len(), 1);
437 }
438
439 #[test]
440 fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
441 let schema = json!({
442 "version": 1,
443 "tables": [
444 {
445 "name": "tenants",
446 "primaryKey": "tenant_id",
447 "columns": [
448 { "name": "tenant_id", "type": "string", "nullable": false },
449 { "name": "body", "type": "string", "nullable": false }
450 ],
451 "scopes": [{ "pattern": "tenant:{tenant_id}" }]
452 },
453 {
454 "name": "buckets",
455 "primaryKey": "id",
456 "columns": [
457 { "name": "id", "type": "string", "nullable": false },
458 { "name": "bucket", "type": "float", "nullable": false },
459 { "name": "body", "type": "string", "nullable": false }
460 ],
461 "scopes": [{ "pattern": "bucket:{bucket}" }]
462 }
463 ]
464 });
465 let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
466 .expect("test client");
467 client.create_synced_tables().unwrap();
468
469 client
471 .conn
472 .execute(
473 "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
474 [],
475 )
476 .unwrap();
477 client
478 .patch(
479 "tenants",
480 "t1",
481 Map::from_iter([
482 ("tenant_id".to_owned(), json!("t1")),
483 ("body".to_owned(), json!("created")),
484 ]),
485 None,
486 )
487 .expect("the primary-key scope column is proven equal by construction");
488
489 client
491 .conn
492 .execute(
493 "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
494 [],
495 )
496 .unwrap();
497 client
498 .patch(
499 "buckets",
500 "b1",
501 Map::from_iter([
502 ("bucket".to_owned(), json!(2)),
503 ("body".to_owned(), json!("coerced")),
504 ]),
505 None,
506 )
507 .expect("the stored REAL value coerces from a supplied integer");
508 let values = client.outbox[1].ops[0]
509 .values
510 .clone()
511 .expect("the patch records values");
512 assert!(!values.contains_key("bucket"));
513
514 let differing = client.patch(
516 "buckets",
517 "b1",
518 Map::from_iter([("bucket".to_owned(), json!(3))]),
519 None,
520 );
521 let differing = differing.expect_err("a differing scope value is rejected");
522 assert_eq!(differing.code, "sync.invalid_request");
523 assert_eq!(differing.message, "the authoring request is invalid");
524 assert!(
525 differing
526 .details
527 .as_ref()
528 .and_then(|details| details["legacyCause"].as_str())
529 .is_some_and(|cause| cause.contains("patch cannot write scope column")),
530 "{differing:?}"
531 );
532 }
533
534 #[cfg(feature = "e2ee")]
535 #[test]
536 fn stored_key_fallback_resolves_an_integer_primary_key() {
537 let schema = json!({
538 "version": 1,
539 "tables": [{
540 "name": "patients",
541 "primaryKey": "id",
542 "columns": [
543 { "name": "id", "type": "integer", "nullable": false },
544 { "name": "encryption_key_id", "type": "string", "nullable": true },
545 { "name": "note", "type": "bytes", "nullable": true,
546 "encrypted": true, "declaredType": "string" }
547 ],
548 "scopes": []
549 }]
550 });
551 let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
552 .expect("test client");
553 client.create_synced_tables().unwrap();
554 client
555 .conn
556 .execute(
557 "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
558 VALUES (5, 'k1', NULL, 1)",
559 [],
560 )
561 .unwrap();
562 let mut config = crate::values::EncryptionConfig::default();
563 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
564 config
565 .key_id_columns
566 .insert("patients".to_owned(), "encryption_key_id".to_owned());
567 client.set_encryption(config);
568 let table = client.schema.table("patients").unwrap();
569 let fallback = client
570 .stored_key_fallback(table, "5")
571 .expect("the stored integer key resolves the fallback");
572 assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
573 }
574
575 #[test]
576 fn patch_types_an_integer_primary_key_from_its_canonical_row_id() {
577 let schema = json!({
578 "version": 1,
579 "tables": [{
580 "name": "counters",
581 "primaryKey": "id",
582 "columns": [
583 { "name": "id", "type": "integer", "nullable": false },
584 { "name": "title", "type": "string", "nullable": false }
585 ],
586 "scopes": []
587 }]
588 });
589 let mut client = SyncClient::new("int-patch".into(), &schema, ClientLimits::default())
590 .expect("test client");
591 client
592 .write_base_row(
593 "counters",
594 &vec![
595 Some(ColumnValue::Integer(42)),
596 Some(ColumnValue::String("before".into())),
597 ],
598 1,
599 )
600 .unwrap();
601 client.rebuild_overlay().expect("rebuild overlay");
602 client
603 .patch(
604 "counters",
605 "42",
606 Map::from_iter([
607 ("id".to_owned(), json!(42)),
608 ("title".to_owned(), json!("after")),
609 ]),
610 None,
611 )
612 .expect("an integer key that renders as the rowId is not a change");
613 let rows = client.query("SELECT id, title FROM counters", &[]).unwrap();
614 assert_eq!(Value::from(rows), json!([{"id": 42, "title": "after"}]));
615 for row_id in ["042", "+42", "42.0"] {
616 let error = client
617 .patch(
618 "counters",
619 row_id,
620 Map::from_iter([("title".to_owned(), json!("x"))]),
621 None,
622 )
623 .expect_err("a non-canonical integer rowId names no row");
624 assert_eq!(error.code, "sync.row_missing", "{row_id}");
625 }
626 }
627
628 #[test]
629 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
630 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
631 {"name":"id","type":"string","nullable":false},
632 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
633 for state in ["absent", "cached", "pinned"] {
634 let path = std::env::temp_dir().join(format!(
635 "syncular-blob-stage-{}.sqlite",
636 uuid::Uuid::new_v4()
637 ));
638 let mut client = SyncClient::open_path(
639 "stage-test".into(),
640 &schema,
641 Default::default(),
642 path.to_str().unwrap(),
643 )
644 .unwrap();
645 let bytes = b"atomic stage";
646 if state != "absent" {
647 client
648 .upload_blob(bytes, Some("text/plain".into()), None)
649 .unwrap();
650 if state == "cached" {
651 client
652 .conn
653 .execute("DELETE FROM _syncular_blob_uploads", [])
654 .unwrap();
655 }
656 }
657 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
658 client
659 .conn
660 .execute_batch(
661 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
662 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
663 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
664 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
665 )
666 .unwrap();
667 let error = client
668 .upload_blob(bytes, Some("application/octet-stream".into()), None)
669 .unwrap_err();
670 assert!(error.contains("injected stage failure"), "{state}: {error}");
671 assert!(client.conn.is_autocommit());
672 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
673 drop(client);
674 let mut reopened = SyncClient::open_path(
675 "stage-test".into(),
676 &schema,
677 Default::default(),
678 path.to_str().unwrap(),
679 )
680 .unwrap();
681 let durable = reopened
682 .query("SELECT * FROM _syncular_blobs", &[])
683 .unwrap();
684 reopened
685 .conn
686 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
687 .unwrap();
688 let retried = reopened.upload_blob(bytes, None, None).unwrap();
689 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
690 assert_eq!(
691 reopened
692 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
693 .unwrap(),
694 vec![Map::from_iter([(
695 "blob_id".into(),
696 Value::from(blob_id_for(bytes)),
697 )])]
698 );
699 drop(reopened);
700 std::fs::remove_file(&path).unwrap();
701 assert_eq!(durable, bodies, "{state}: reopened body state");
702 assert_eq!(immediate, bodies, "{state}: immediate body state");
703 }
704 }
705
706 #[test]
707 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
708 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
709 {"name":"id","type":"string","nullable":false},
710 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
711 for fault in [
712 "missing",
713 "body-type",
714 "length",
715 "hash",
716 "pin-metadata",
717 "read",
718 "pin-delete",
719 ] {
720 let path = std::env::temp_dir().join(format!(
721 "syncular-blob-upload-{}.sqlite",
722 uuid::Uuid::new_v4()
723 ));
724 let mut client = SyncClient::open_path(
725 "upload-test".into(),
726 &schema,
727 Default::default(),
728 path.to_str().unwrap(),
729 )
730 .unwrap();
731 let bytes = b"durable upload";
732 let ref_value = client
733 .upload_blob(bytes, Some("text/plain".into()), None)
734 .unwrap();
735 let commit = client
736 .mutate(vec![Mutation::Upsert {
737 table: "attachments".into(),
738 values: Map::from_iter([
739 ("id".into(), Value::from("pending")),
740 (
741 "file".into(),
742 Value::from(format!(
743 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
744 blob_id_for(bytes),
745 bytes.len()
746 )),
747 ),
748 ]),
749 base_version: None,
750 }])
751 .unwrap();
752 client
753 .conn
754 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
755 .unwrap();
756 client.conn.execute_batch(match fault {
757 "missing" => "DELETE FROM _syncular_blobs",
758 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
759 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
760 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
761 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
762 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
763 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
764 }).unwrap();
765 let state = if fault == "read" {
766 Vec::new()
767 } else {
768 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
769 };
770 let mut transport = CountingRealtimeTransport::default();
771 let error = client.flush_blob_uploads(&mut transport).unwrap_err();
772 assert_eq!(
773 error.code,
774 if matches!(fault, "read" | "pin-delete") {
775 "client.failed"
776 } else {
777 "sync.local_corrupt"
778 },
779 "{fault}"
780 );
781 assert_eq!(
782 transport.blob_uploads.len(),
783 usize::from(fault == "pin-delete"),
784 "{fault}: corrupt bytes must never be uploaded"
785 );
786 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
787 if fault != "read" {
788 assert_eq!(
789 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
790 state
791 );
792 } else {
793 client
794 .conn
795 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
796 .unwrap();
797 }
798 drop(client);
799 let mut reopened = SyncClient::open_path(
800 "upload-test".into(),
801 &schema,
802 Default::default(),
803 path.to_str().unwrap(),
804 )
805 .unwrap();
806 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
807 if fault != "read" {
808 assert_eq!(
809 reopened
810 .query("SELECT * FROM _syncular_blobs", &[])
811 .unwrap(),
812 state
813 );
814 }
815 if fault == "pin-delete" {
816 reopened
817 .conn
818 .execute_batch("DROP TRIGGER fail_pin_delete")
819 .unwrap();
820 }
821 if fault == "pin-metadata" {
822 reopened
823 .conn
824 .execute(
825 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
826 [],
827 )
828 .unwrap();
829 }
830 reopened
831 .conn
832 .execute_batch(
833 "DELETE FROM _syncular_blobs;
834 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
835 )
836 .unwrap();
837 transport.blob_uploads.clear();
838 reopened.flush_blob_uploads(&mut transport).unwrap();
839 assert_eq!(
840 transport.blob_uploads,
841 [(blob_id_for(bytes), bytes.to_vec())]
842 );
843 assert_eq!(
844 reopened
845 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
846 .unwrap(),
847 Vec::<Map<String, Value>>::new()
848 );
849 assert_eq!(reopened.pending_commit_ids(), [commit]);
850 assert_eq!(
851 reopened
852 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
853 .unwrap()
854 .unwrap()
855 .bytes,
856 bytes
857 );
858 drop(reopened);
859 std::fs::remove_file(path).unwrap();
860 }
861 }
862
863 #[test]
864 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
865 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
866 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
867 "scopes":[{"pattern":"project:{project_id}"}]}]});
868 for realtime in [false, true] {
869 for fault in [
870 "none",
871 "row",
872 "revision",
873 "commit",
874 "cursor",
875 "error",
876 "missing-end",
877 ] {
878 if realtime && fault == "missing-end" {
879 continue;
880 }
881 let path = std::env::temp_dir()
882 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
883 let mut client = SyncClient::open_path(
884 "frame-test".into(),
885 &schema,
886 Default::default(),
887 path.to_str().unwrap(),
888 )
889 .unwrap();
890 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
891 client
892 .subscribe(
893 "tasks".into(),
894 "tasks".into(),
895 vec![("project_id".into(), vec!["p1".into()])],
896 None,
897 )
898 .unwrap();
899 client.subs[0].cursor = 0;
900 client.subs[0].synced_once = true;
901 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
902 client.persist_sub(&client.subs[0]).unwrap();
903 client.realtime_state = RealtimeState::Connected;
904 client.realtime_reason_code = None;
905 client.drain_change_batches();
906 let mut transport = CountingRealtimeTransport::default();
907 let (_, meta) = client.build_request(false).unwrap();
908 let mut response = Message {
909 wire_version: WIRE_VERSION,
910 msg_kind: MsgKind::Response,
911 frames: vec![
912 Frame::RespHeader {
913 required_schema_version: None,
914 latest_schema_version: None,
915 log_epoch: Some("epoch-1".into()),
916 reset_required: Some(false),
917 },
918 Frame::SubStart {
919 id: "tasks".into(),
920 status: SubStatus::Active,
921 reason_code: "".into(),
922 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
923 bootstrap: false,
924 },
925 ],
926 };
927 for (index, id) in ["first", "second"].iter().enumerate() {
928 let values = Map::from_iter([
929 ("id".into(), Value::from(*id)),
930 ("project_id".into(), Value::from("p1")),
931 ]);
932 response.frames.push(Frame::Commit {
933 commit_seq: index as i64 + 1,
934 actor_id: "writer".into(),
935 created_at_ms: 1,
936 tables: vec!["tasks".into()],
937 changes: vec![ssp2::model::Change {
938 table_index: 0,
939 row_id: (*id).into(),
940 op: Op::Upsert,
941 row_version: Some(index as i64 + 1),
942 scopes: vec![("project_id".into(), "p1".into())],
943 row: Some(
944 encode_row_json(
945 client.schema.table("tasks").unwrap(),
946 id,
947 &values,
948 &client.encryption,
949 )
950 .unwrap(),
951 ),
952 }],
953 });
954 }
955 response.frames.push(Frame::SubEnd {
956 next_cursor: 2,
957 bootstrap_state: None,
958 });
959 let valid = response.clone();
960 match fault {
961 "row" => {
962 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
963 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
964 },
965 "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(),
966 "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(),
967 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
968 "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});},
969 "missing-end" => {response.frames.truncate(3);},
970 _ => {}
971 }
972 if realtime {
973 client.on_realtime_binary(&mut transport, &encode_message(&response));
974 } else {
975 assert_eq!(
976 matches!(
977 client.process_response(&mut transport, response, &meta),
978 SyncOutcome::Ok(_)
979 ),
980 fault == "none",
981 "{fault}"
982 );
983 }
984 assert!(
985 client.conn.is_autocommit(),
986 "no open transaction after {realtime}/{fault}"
987 );
988 let count = if matches!(fault, "none" | "cursor") {
989 2
990 } else {
991 1
992 };
993 let expected_revision = count + u64::from(realtime && fault != "none");
994 assert_eq!(
995 client.local_revision(),
996 expected_revision,
997 "{realtime}/{fault}"
998 );
999 let batches = client.drain_change_batches();
1000 assert_eq!(
1001 batches.len(),
1002 expected_revision as usize,
1003 "{realtime}/{fault}"
1004 );
1005 let row_batches: Vec<_> = batches
1006 .iter()
1007 .filter(|batch| !batch.tables.is_empty())
1008 .collect();
1009 assert_eq!(row_batches.len(), count as usize);
1010 assert_eq!(
1011 row_batches
1012 .iter()
1013 .map(|b| b.revision.clone())
1014 .collect::<Vec<_>>(),
1015 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
1016 );
1017 for table in ["tasks", "_syncular_base_tasks"] {
1018 assert_eq!(
1019 client
1020 .conn
1021 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
1022 .get::<_, u64>(0))
1023 .unwrap(),
1024 count,
1025 "{realtime}/{fault}/{table}"
1026 );
1027 }
1028 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1029 assert_eq!(
1030 transport
1031 .messages
1032 .iter()
1033 .filter(|m| m.contains("\"ack\""))
1034 .count(),
1035 usize::from(fault == "none")
1036 );
1037 if matches!(fault, "revision" | "commit" | "cursor") {
1038 client
1039 .conn
1040 .execute_batch("DROP TRIGGER fail_frame")
1041 .unwrap();
1042 }
1043 drop(client);
1044 let mut reopened = SyncClient::open_path(
1045 "frame-test".into(),
1046 &schema,
1047 Default::default(),
1048 path.to_str().unwrap(),
1049 )
1050 .unwrap();
1051 assert_eq!(reopened.local_revision(), expected_revision);
1052 assert_eq!(
1053 reopened
1054 .query("SELECT id FROM tasks ORDER BY id", &[])
1055 .unwrap()
1056 .len(),
1057 count as usize
1058 );
1059 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1060 let (_, meta) = reopened.build_request(false).unwrap();
1061 assert!(matches!(
1062 reopened.process_response(&mut transport, valid, &meta),
1063 SyncOutcome::Ok(_)
1064 ));
1065 assert_eq!(
1066 reopened
1067 .query("SELECT id FROM tasks ORDER BY id", &[])
1068 .unwrap()
1069 .len(),
1070 2
1071 );
1072 assert_eq!(reopened.subs[0].cursor, 2);
1073 drop(reopened);
1074 std::fs::remove_file(path).unwrap();
1075 }
1076 }
1077 }
1078
1079 #[test]
1080 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
1081 for status in [SubStatus::Reset, SubStatus::Revoked] {
1082 for fault in ["subscription", "revision"] {
1083 let mut client = client();
1084 client
1085 .subscribe(
1086 "tasks".into(),
1087 "tasks".into(),
1088 vec![("project_id".into(), vec!["p1".into()])],
1089 None,
1090 )
1091 .unwrap();
1092 client.subs[0].cursor = 41;
1093 client.subs[0].synced_once = true;
1094 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1095 client.persist_sub(&client.subs[0]).unwrap();
1096 client
1097 .mutate(vec![Mutation::Upsert {
1098 table: "tasks".into(),
1099 values: Map::from_iter([
1100 ("id".into(), Value::from("pending")),
1101 ("project_id".into(), Value::from("p1")),
1102 ]),
1103 base_version: None,
1104 }])
1105 .unwrap();
1106 let ids = client.pending_commit_ids();
1107 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
1108 let revision = client.local_revision();
1109 client.drain_change_batches();
1110 let (_, meta) = client.build_request(false).unwrap();
1111 client.conn.execute_batch(if fault == "subscription" {
1112 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
1113 } else {
1114 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
1115 }).unwrap();
1116 let mut transport = CountingRealtimeTransport::default();
1117 let mut report = SyncReport::default();
1118 if status == SubStatus::Reset {
1120 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
1121 }
1122 let result = client.process_section(
1123 &mut transport,
1124 "tasks",
1125 status,
1126 "sync.scope_revoked",
1127 vec![],
1128 vec![],
1129 Some((42, None)),
1130 &meta,
1131 &mut report,
1132 );
1133 assert!(result.is_err(), "{status:?}/{fault}");
1134 assert!(client.conn.is_autocommit());
1135 assert_eq!(client.subs[0].cursor, 41);
1136 assert_eq!(client.subs[0].state, SubState::Active);
1137 assert_eq!(client.pending_commit_ids(), ids);
1138 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1139 assert!(client.rejections.is_empty());
1140 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
1141 assert_eq!(client.local_revision(), revision);
1142 assert!(client.drain_change_batches().is_empty());
1143 assert!(report.revoked.is_empty() && report.resets.is_empty());
1144 }
1145 }
1146 }
1147
1148 #[test]
1149 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
1150 let mut client = client();
1151 client
1152 .subscribe(
1153 "tasks".into(),
1154 "tasks".into(),
1155 vec![("project_id".into(), vec!["p1".into()])],
1156 None,
1157 )
1158 .unwrap();
1159 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1160 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
1161 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
1162 let row = |id: &str| ssp2::segment::SegmentRow {
1163 server_version: 1,
1164 values: vec![
1165 Some(ColumnValue::String(id.into())),
1166 Some(ColumnValue::String("p1".into())),
1167 ],
1168 };
1169 let segment = RowsSegment {
1170 table: "tasks".into(),
1171 schema_version: 1,
1172 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
1173 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
1174 };
1175 assert!(client.apply_segment(0, &segment, true).is_err());
1176 assert!(client.conn.is_autocommit());
1177 assert_eq!(
1178 client
1179 .query("SELECT id FROM tasks ORDER BY id", &[])
1180 .unwrap(),
1181 vec![Map::from_iter([("id".into(), Value::from("first"))])]
1182 );
1183 assert_eq!(client.local_revision(), 1);
1184 assert_eq!(client.drain_change_batches().len(), 1);
1185 assert_eq!(client.subs[0].cursor, -1);
1186 client
1187 .conn
1188 .execute_batch("DROP TRIGGER fail_block")
1189 .unwrap();
1190 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
1191 assert_eq!(client.local_revision(), 3);
1192 assert_eq!(client.drain_change_batches().len(), 2);
1193 }
1194
1195 #[test]
1196 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1197 assert_eq!(
1198 client().diagnostics_storage().blob_cache_bytes_approx,
1199 Some(0)
1200 );
1201 let client = SyncClient::new(
1202 "diagnostic-storage".to_owned(),
1203 &json!({
1204 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1205 "columns": [{"name": "id", "type": "string", "nullable": false},
1206 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1207 }),
1208 ClientLimits {
1209 blob_cache_max_bytes: Some(5),
1210 ..Default::default()
1211 },
1212 )
1213 .unwrap();
1214 let empty = client.diagnostics_storage();
1215 assert_eq!(empty.status, "healthy");
1216 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1217 assert_eq!(empty.retained_outcome_entries, Some(0));
1218 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1219 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1220 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1221 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1222 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1223 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1224 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1225 let written = client.diagnostics_storage();
1226 assert_eq!(written.status, "pressure");
1227 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1228 assert_eq!(written.retained_outcome_entries, Some(2));
1229 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1230 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1231 assert_eq!(
1232 written.pressure_reason_code.as_deref(),
1233 Some("client.blob_cache_over_limit")
1234 );
1235 client
1236 .conn
1237 .execute_batch(
1238 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1239 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1240 )
1241 .unwrap();
1242 let staged = client.diagnostics_storage();
1243 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1244 assert_eq!(staged.retained_outcome_entries, Some(0));
1245 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1246 assert_eq!(staged.status, "healthy");
1247 client.conn.execute_batch("ROLLBACK").unwrap();
1248 assert_eq!(client.diagnostics_storage(), written);
1249 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1250 let unreadable = client.diagnostics_storage();
1251 assert_eq!(unreadable.status, "unreadable");
1252 assert!(unreadable.database_bytes_approx.is_none());
1253 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1254 assert!(unreadable.retained_outcome_entries.is_none());
1255 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1256 assert!(unreadable.blob_cache_bytes_approx.is_none());
1257 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1258 let restored = client.diagnostics_storage();
1259 assert_eq!(restored.status, written.status);
1260 assert_eq!(
1261 restored.retained_outcome_bytes_approx,
1262 written.retained_outcome_bytes_approx
1263 );
1264 client.conn.set_prepared_statement_cache_capacity(0);
1265 assert_eq!(client.diagnostics_storage(), restored);
1266 }
1267
1268 struct ServerErrorTransport {
1270 code: &'static str,
1271 retryable: bool,
1272 }
1273
1274 impl Transport for ServerErrorTransport {
1275 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1276 Ok(encode_message(&Message {
1277 wire_version: WIRE_VERSION,
1278 msg_kind: MsgKind::Response,
1279 frames: vec![
1280 Frame::RespHeader {
1281 required_schema_version: None,
1282 latest_schema_version: None,
1283 log_epoch: Some("epoch-1".to_owned()),
1284 reset_required: Some(false),
1285 },
1286 Frame::Error {
1287 code: self.code.to_owned(),
1288 message: "server error".to_owned(),
1289 category: "internal".to_owned(),
1290 retryable: self.retryable,
1291 recommended_action: "retryLater".to_owned(),
1292 details: None,
1293 },
1294 ],
1295 }))
1296 }
1297
1298 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1299 self.sync(request)
1300 }
1301
1302 fn download_segment(
1303 &mut self,
1304 _request: &SegmentRequest,
1305 _on_progress: &mut dyn FnMut(u64),
1306 ) -> Result<Vec<u8>, TransportError> {
1307 Err(TransportError::new("sync.transport_failed", "offline"))
1308 }
1309
1310 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1311 Ok(())
1312 }
1313
1314 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1315 Ok(())
1316 }
1317
1318 fn realtime_close(&mut self) -> Result<(), TransportError> {
1319 Ok(())
1320 }
1321 }
1322
1323 #[test]
1324 fn retryable_server_error_schedules_a_background_retry() {
1325 let mut client = client();
1326 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1327 let mut transport = ServerErrorTransport {
1328 code: "sync.internal_error",
1329 retryable: true,
1330 };
1331 assert!(matches!(
1332 client.sync(&mut transport),
1333 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1334 ));
1335 assert!(matches!(
1336 client.drain_sync_intents().as_slice(),
1337 [SyncIntent::Background { delay_ms: 250 }]
1338 ));
1339 assert_eq!(
1340 client.progress().snapshot().unwrap().retry_delay_ms,
1341 Some(250)
1342 );
1343 let mut transport = ServerErrorTransport {
1344 code: "sync.invalid_request",
1345 retryable: false,
1346 };
1347 assert!(matches!(
1348 client.sync(&mut transport),
1349 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1350 ));
1351 assert!(client.drain_sync_intents().is_empty());
1352 let progress = client.progress().snapshot().unwrap();
1353 assert_eq!(progress.state, ProgressState::Failed);
1354 assert_eq!(progress.retry_delay_ms, None);
1355 }
1356
1357 #[test]
1358 fn sync_until_idle_reports_budget_exhaustion_or_failure() {
1359 let exhausted = SyncOutcome::BudgetExhausted(SyncReport::default()).to_json();
1362 assert_eq!(exhausted["ok"], json!(true));
1363 assert_eq!(exhausted["budgetExhausted"], json!(true));
1364 assert!(exhausted["report"].is_object());
1365
1366 let mut client = client();
1367 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1368 let mut idle_transport = HostTransport::new_from_config(&json!({})).unwrap();
1370 let zero = client.sync_until_idle(&mut idle_transport, Some(0));
1371 assert!(
1372 matches!(zero, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
1373 "{zero:?}"
1374 );
1375 let mut erroring = ServerErrorTransport {
1377 code: "sync.internal_error",
1378 retryable: false,
1379 };
1380 let failed = client.sync_until_idle(&mut erroring, Some(3));
1381 assert!(
1382 matches!(failed, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1383 "{failed:?}"
1384 );
1385 }
1386
1387 struct ScriptedSyncTransport {
1390 responses: std::collections::VecDeque<Message>,
1391 calls: usize,
1392 }
1393
1394 impl Transport for ScriptedSyncTransport {
1395 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1396 self.calls += 1;
1397 let response = self.responses.pop_front().expect("scripted response");
1398 Ok(encode_message(&response))
1399 }
1400
1401 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1402 self.sync(request)
1403 }
1404
1405 fn download_segment(
1406 &mut self,
1407 _request: &SegmentRequest,
1408 _on_progress: &mut dyn FnMut(u64),
1409 ) -> Result<Vec<u8>, TransportError> {
1410 Err(TransportError::new("sync.transport_failed", "offline"))
1411 }
1412
1413 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1414 Ok(())
1415 }
1416
1417 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1418 Ok(())
1419 }
1420
1421 fn realtime_close(&mut self) -> Result<(), TransportError> {
1422 Ok(())
1423 }
1424 }
1425
1426 #[test]
1427 fn sync_until_idle_success_then_failure_keeps_earlier_work() {
1428 let path = std::env::temp_dir().join(format!(
1429 "syncular-budget-script-{}.sqlite",
1430 uuid::Uuid::new_v4()
1431 ));
1432 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1433 {"name":"id","type":"string","nullable":false},
1434 {"name":"project_id","type":"string","nullable":false}],
1435 "scopes":[{"pattern":"project:{project_id}"}]}]});
1436 let mut client = SyncClient::open_path(
1437 "budget-script".into(),
1438 &schema,
1439 ClientLimits::default(),
1440 path.to_str().unwrap(),
1441 )
1442 .expect("open client");
1443 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1444 client
1445 .subscribe(
1446 "tasks".into(),
1447 "tasks".into(),
1448 vec![("project_id".into(), vec!["p1".into()])],
1449 None,
1450 )
1451 .unwrap();
1452 client.subs[0].cursor = 0;
1453 client.subs[0].synced_once = true;
1454 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1455 client.persist_sub(&client.subs[0]).unwrap();
1456 client.drain_change_batches();
1457
1458 let row = encode_row_json(
1459 client.schema.table("tasks").unwrap(),
1460 "kept",
1461 &Map::from_iter([
1462 ("id".into(), json!("kept")),
1463 ("project_id".into(), json!("p1")),
1464 ]),
1465 &client.encryption,
1466 )
1467 .unwrap();
1468 let success = Message {
1469 wire_version: WIRE_VERSION,
1470 msg_kind: MsgKind::Response,
1471 frames: vec![
1472 Frame::RespHeader {
1473 required_schema_version: None,
1474 latest_schema_version: None,
1475 log_epoch: Some("epoch-1".into()),
1476 reset_required: Some(false),
1477 },
1478 Frame::SubStart {
1479 id: "tasks".into(),
1480 status: SubStatus::Active,
1481 reason_code: String::new(),
1482 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1483 bootstrap: false,
1484 },
1485 Frame::Commit {
1486 commit_seq: 1,
1487 created_at_ms: 1,
1488 actor_id: "writer".into(),
1489 tables: vec!["tasks".into()],
1490 changes: vec![ssp2::model::Change {
1491 table_index: 0,
1492 row_id: "kept".into(),
1493 op: Op::Upsert,
1494 row_version: Some(1),
1495 scopes: vec![("project_id".into(), "p1".into())],
1496 row: Some(row),
1497 }],
1498 },
1499 Frame::SubEnd {
1500 next_cursor: 1,
1501 bootstrap_state: None,
1502 },
1503 ],
1504 };
1505 let failure = Message {
1506 wire_version: WIRE_VERSION,
1507 msg_kind: MsgKind::Response,
1508 frames: vec![
1509 Frame::RespHeader {
1510 required_schema_version: None,
1511 latest_schema_version: None,
1512 log_epoch: Some("epoch-1".into()),
1513 reset_required: Some(false),
1514 },
1515 Frame::Error {
1516 code: "sync.internal_error".into(),
1517 message: "second round failed".into(),
1518 category: "internal".into(),
1519 retryable: false,
1520 recommended_action: "retryLater".into(),
1521 details: None,
1522 },
1523 ],
1524 };
1525 let mut transport = ScriptedSyncTransport {
1526 responses: std::collections::VecDeque::from([success, failure]),
1527 calls: 0,
1528 };
1529 let outcome = client.sync_until_idle(&mut transport, Some(2));
1530 assert!(
1531 matches!(outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1532 "{outcome:?}"
1533 );
1534 assert_eq!(
1535 transport.calls, 2,
1536 "one successful round then the failing round"
1537 );
1538 assert_eq!(
1539 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
1540 json!("kept"),
1541 "the earlier round's durable work survives the later failure"
1542 );
1543 drop(client);
1544 let _ = std::fs::remove_file(&path);
1545 }
1546
1547 #[test]
1548 fn sync_until_idle_stops_on_a_schema_floor() {
1549 let mut client = client();
1550 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1551 let floor = Message {
1552 wire_version: WIRE_VERSION,
1553 msg_kind: MsgKind::Response,
1554 frames: vec![Frame::RespHeader {
1555 required_schema_version: Some(2),
1556 latest_schema_version: Some(2),
1557 log_epoch: Some("epoch-1".into()),
1558 reset_required: Some(false),
1559 }],
1560 };
1561 let mut transport = ScriptedSyncTransport {
1562 responses: std::collections::VecDeque::from([floor.clone(), floor]),
1563 calls: 0,
1564 };
1565 let outcome = client.sync_until_idle(&mut transport, Some(3));
1566 match outcome {
1567 SyncOutcome::Ok(report) => assert_eq!(
1568 report
1569 .schema_floor
1570 .as_ref()
1571 .and_then(|floor| floor.required_schema_version),
1572 Some(2),
1573 "the stop retains the latest schema floor"
1574 ),
1575 other => panic!("expected a schema-floor stop, got {other:?}"),
1576 }
1577 assert_eq!(
1578 transport.calls, 1,
1579 "a schema floor stops the budget loop after one round"
1580 );
1581 }
1582
1583 #[test]
1584 fn sync_report_merge_schema_floor_is_latest_state() {
1585 let mut aggregate = SyncReport {
1586 schema_floor: Some(SchemaFloor {
1587 required_schema_version: Some(2),
1588 latest_schema_version: Some(2),
1589 }),
1590 ..SyncReport::default()
1591 };
1592 aggregate.merge(&SyncReport::default());
1593 assert!(
1594 aggregate.schema_floor.is_none(),
1595 "a later round without a floor clears it"
1596 );
1597 }
1598
1599 #[test]
1600 fn push_capacity_limits_are_hard_and_keep_intent() {
1601 let mut c = client();
1603 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1604 c.limits.max_push_operations_per_request = Some(1);
1605 let id = c
1606 .mutate(vec![
1607 Mutation::Upsert {
1608 table: "tasks".into(),
1609 values: Map::from_iter([
1610 ("id".into(), json!("a")),
1611 ("project_id".into(), json!("p1")),
1612 ]),
1613 base_version: None,
1614 },
1615 Mutation::Upsert {
1616 table: "tasks".into(),
1617 values: Map::from_iter([
1618 ("id".into(), json!("b")),
1619 ("project_id".into(), json!("p1")),
1620 ]),
1621 base_version: None,
1622 },
1623 ])
1624 .unwrap();
1625 let error = c.build_request(false).unwrap_err();
1626 let SyncOutcome::Failed {
1627 error_code,
1628 details,
1629 ..
1630 } = *error
1631 else {
1632 panic!("expected a capacity failure");
1633 };
1634 assert_eq!(error_code, "client.push_request_too_large");
1635 let details = details.expect("capacity details");
1636 assert_eq!(details["kind"], json!("operations"));
1637 assert_eq!(details["limit"], json!(1));
1638 assert_eq!(details["size"], json!(2));
1639 assert_eq!(details["clientCommitId"], json!(id));
1640 assert_eq!(c.pending_commit_ids(), vec![id]);
1641
1642 let mut c = client();
1644 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1645 c.limits.max_push_commits_per_request = Some(1);
1646 let first = c
1647 .mutate(vec![Mutation::Upsert {
1648 table: "tasks".into(),
1649 values: Map::from_iter([
1650 ("id".into(), json!("one")),
1651 ("project_id".into(), json!("p1")),
1652 ]),
1653 base_version: None,
1654 }])
1655 .unwrap();
1656 let second = c
1657 .mutate(vec![Mutation::Upsert {
1658 table: "tasks".into(),
1659 values: Map::from_iter([
1660 ("id".into(), json!("two")),
1661 ("project_id".into(), json!("p1")),
1662 ]),
1663 base_version: None,
1664 }])
1665 .unwrap();
1666 let (_, meta) = c.build_request(false).unwrap();
1667 assert_eq!(meta.pushed_ids, vec![first.clone()]);
1668 assert_eq!(meta.deferred_commits, 1);
1669 assert_eq!(c.pending_commit_ids(), vec![first, second]);
1670 }
1671
1672 #[test]
1673 fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
1674 let mut c = client();
1677 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1678 let first = c
1679 .mutate(vec![Mutation::Upsert {
1680 table: "tasks".into(),
1681 values: Map::from_iter([
1682 ("id".into(), json!("one")),
1683 ("project_id".into(), json!("p1")),
1684 ]),
1685 base_version: None,
1686 }])
1687 .unwrap();
1688 let second = c
1689 .mutate(vec![Mutation::Upsert {
1690 table: "tasks".into(),
1691 values: Map::from_iter([
1692 ("id".into(), json!("two")),
1693 ("project_id".into(), json!("p1")),
1694 ]),
1695 base_version: None,
1696 }])
1697 .unwrap();
1698 let (message, meta) = c.build_request(false).unwrap();
1699 assert_eq!(meta.pushed_ids.len(), 2);
1700 let full = encode_message(&message).len();
1701 c.limits.max_push_request_bytes = Some(full - 1);
1702 let (_, meta) = c.build_request(false).unwrap();
1703 assert_eq!(meta.pushed_ids, vec![first.clone()]);
1704 assert_eq!(meta.deferred_commits, 1);
1705 assert_eq!(c.pending_commit_ids(), vec![first, second]);
1706
1707 let mut c = client();
1710 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1711 let huge = c
1712 .mutate(vec![Mutation::Upsert {
1713 table: "tasks".into(),
1714 values: Map::from_iter([
1715 ("id".into(), json!("x".repeat(20_000))),
1716 ("project_id".into(), json!("p1")),
1717 ]),
1718 base_version: None,
1719 }])
1720 .unwrap();
1721 let (message, meta) = c.build_request(false).unwrap();
1722 assert_eq!(meta.pushed_ids, vec![huge.clone()]);
1723 let with_huge = encode_message(&message).len();
1724 c.limits.max_push_request_bytes = Some(with_huge - 1);
1725 let error = c.build_request(false).unwrap_err();
1726 let SyncOutcome::Failed {
1727 error_code,
1728 details,
1729 ..
1730 } = *error
1731 else {
1732 panic!("expected a capacity failure");
1733 };
1734 assert_eq!(error_code, "client.push_request_too_large");
1735 let details = details.expect("capacity details");
1736 assert_eq!(details["kind"], json!("bytes"));
1737 assert_eq!(details["limit"], json!((with_huge - 1) as u64));
1738 assert_eq!(details["size"], json!(with_huge as u64));
1739 assert_eq!(details["clientCommitId"], json!(huge));
1740 assert_eq!(c.pending_commit_ids(), vec![huge]);
1741
1742 let mut c = client();
1744 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1745 c.limits.max_push_request_bytes = Some(1);
1746 let error = c.build_request(false).unwrap_err();
1747 let SyncOutcome::Failed { details, .. } = *error else {
1748 panic!("expected a capacity failure");
1749 };
1750 let details = details.expect("capacity details");
1751 assert_eq!(details["kind"], json!("bytes"));
1752 assert!(details.get("clientCommitId").is_none(), "{details}");
1753 }
1754
1755 #[test]
1756 fn push_limits_validate_at_construction() {
1757 for limits in [
1758 ClientLimits {
1759 max_push_commits_per_request: Some(0),
1760 ..Default::default()
1761 },
1762 ClientLimits {
1763 max_push_operations_per_request: Some(0),
1764 ..Default::default()
1765 },
1766 ClientLimits {
1767 max_push_request_bytes: Some(0),
1768 ..Default::default()
1769 },
1770 ClientLimits {
1771 max_push_request_bytes: Some(u32::MAX as usize + 1),
1772 ..Default::default()
1773 },
1774 ] {
1775 let schema = json!({"version":1,"tables":[]});
1776 let error = SyncClient::with_connection(
1777 "bad-limits".into(),
1778 &schema,
1779 limits,
1780 Connection::open_in_memory().unwrap(),
1781 )
1782 .err();
1783 assert_eq!(
1784 error.as_deref(),
1785 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
1786 );
1787 }
1788 }
1789
1790 #[test]
1791 fn open_path_rejects_invalid_push_limits_before_storage() {
1792 for identity in [false, true] {
1793 let path = std::env::temp_dir().join(format!(
1794 "syncular-bad-limits-{}.sqlite",
1795 uuid::Uuid::new_v4()
1796 ));
1797 let schema = json!({"version":1,"tables":[]});
1798 let limits = ClientLimits {
1799 max_push_request_bytes: Some(0),
1800 ..Default::default()
1801 };
1802 let error = if identity {
1803 SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
1804 } else {
1805 SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
1806 }
1807 .err();
1808 assert_eq!(
1809 error.as_deref(),
1810 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
1811 );
1812 assert!(
1813 !path.exists(),
1814 "an invalid limit is rejected before the database file is created"
1815 );
1816 }
1817 }
1818
1819 #[test]
1820 fn failed_progress_retry_delay_doubles_to_the_cap() {
1821 let mut client = client();
1822 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1823 let mut transport = ServerErrorTransport {
1824 code: "sync.internal_error",
1825 retryable: true,
1826 };
1827 let delays: Vec<Option<u64>> = (0..9)
1828 .map(|_| {
1829 client.sync(&mut transport);
1830 client.progress().snapshot().unwrap().retry_delay_ms
1831 })
1832 .collect();
1833 assert_eq!(
1834 delays,
1835 [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
1836 );
1837 }
1838
1839 #[test]
1840 fn background_retry_deadlines_back_off_and_reset() {
1841 let mut client = client();
1842 client.schedule_background_retry();
1843 assert!(matches!(
1844 client.drain_sync_intents().as_slice(),
1845 [SyncIntent::Background { delay_ms: 250 }]
1846 ));
1847 client.schedule_background_retry();
1848 assert!(matches!(
1849 client.drain_sync_intents().as_slice(),
1850 [SyncIntent::Background { delay_ms: 500 }]
1851 ));
1852 client.reset_background_retry();
1853 client.schedule_background_retry();
1854 assert!(matches!(
1855 client.drain_sync_intents().as_slice(),
1856 [SyncIntent::Background { delay_ms: 250 }]
1857 ));
1858 }
1859
1860 #[test]
1861 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1862 let mut client = client();
1863 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1864 client
1865 .subscribe(
1866 "epoch-tasks".to_owned(),
1867 "tasks".to_owned(),
1868 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1869 None,
1870 )
1871 .expect("subscribe");
1872 client.drain_sync_intents();
1873
1874 client
1875 .run_log_epoch_reset("epoch-2")
1876 .expect("reset partition log epoch");
1877
1878 assert!(matches!(
1879 client.drain_sync_intents().as_slice(),
1880 [SyncIntent::Interactive]
1881 ));
1882 }
1883
1884 #[derive(Default)]
1885 struct CountingRealtimeTransport {
1886 connects: usize,
1887 closes: usize,
1888 messages: Vec<String>,
1889 blob_uploads: Vec<(String, Vec<u8>)>,
1890 blob_download: Option<Vec<u8>>,
1891 sync_calls: usize,
1892 realtime_sync_calls: usize,
1893 refuse_connect: bool,
1894 }
1895
1896 impl Transport for CountingRealtimeTransport {
1897 fn blob_upload(
1898 &mut self,
1899 blob_id: &str,
1900 bytes: &[u8],
1901 _media_type: Option<&str>,
1902 ) -> Result<(), TransportError> {
1903 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1904 Ok(())
1905 }
1906
1907 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1908 self.blob_download
1909 .clone()
1910 .map(BlobDownload::Bytes)
1911 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1912 }
1913
1914 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1915 self.sync_calls += 1;
1916 Err(TransportError::new("sync.transport_failed", "offline"))
1917 }
1918
1919 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1920 self.realtime_sync_calls += 1;
1921 Err(TransportError::new("sync.transport_failed", "offline"))
1922 }
1923
1924 fn download_segment(
1925 &mut self,
1926 _request: &SegmentRequest,
1927 _on_progress: &mut dyn FnMut(u64),
1928 ) -> Result<Vec<u8>, TransportError> {
1929 Err(TransportError::new("sync.transport_failed", "offline"))
1930 }
1931
1932 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1933 self.connects += 1;
1934 if self.refuse_connect {
1935 return Err(TransportError::new("transport.failed", "refused"));
1936 }
1937 Ok(())
1938 }
1939
1940 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1941 self.messages.push(text.to_owned());
1942 Ok(())
1943 }
1944
1945 fn realtime_close(&mut self) -> Result<(), TransportError> {
1946 self.closes += 1;
1947 Ok(())
1948 }
1949 }
1950
1951 #[test]
1952 fn blob_results_own_downloaded_bytes() {
1953 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1954 {"name":"id","type":"string","nullable":false},
1955 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1956 let bytes = b"owned typed result".to_vec();
1957 let blob_id = blob_id_for(&bytes);
1958 let mut transport = CountingRealtimeTransport {
1959 blob_download: Some(bytes.clone()),
1960 ..Default::default()
1961 };
1962 let result = {
1963 let mut client =
1964 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1965 .unwrap();
1966 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1967 assert_eq!(result.blob_id, blob_id);
1968 assert_eq!(result.byte_length, bytes.len() as i64);
1969 assert_eq!(result.bytes, bytes);
1970 assert_eq!(result.media_type, None);
1971 assert_eq!(
1972 client
1973 .conn
1974 .query_row(
1975 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1976 rusqlite::params![blob_id],
1977 |row| row.get::<_, i64>(0),
1978 )
1979 .unwrap(),
1980 0
1981 );
1982 result
1983 };
1984 assert_eq!(result.bytes, bytes);
1985
1986 let mut client =
1987 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1988 assert_eq!(
1989 client
1990 .fetch_blob_bytes(&mut transport, "not json")
1991 .unwrap_err(),
1992 (
1993 "client.failed".to_owned(),
1994 "blob ref is not JSON".to_owned()
1995 )
1996 );
1997 }
1998
1999 #[test]
2000 fn visible_blob_references_survive_cache_trimming() {
2001 let schema = json!({"version":1,"tables":[
2002 {"name":"attachments","primaryKey":"id","columns":[
2003 {"name":"id","type":"string","nullable":false},
2004 {"name":"body","type":"blob_ref","nullable":true},
2005 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
2006 {"name":"avatars","primaryKey":"id","columns":[
2007 {"name":"id","type":"string","nullable":false},
2008 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
2009 ]});
2010 let bytes = b"referenced above cap".to_vec();
2011 let blob_id = blob_id_for(&bytes);
2012 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
2013 let mut client = SyncClient::new(
2014 "visible-blob-refs".to_owned(),
2015 &schema,
2016 ClientLimits {
2017 blob_cache_max_bytes: Some(1),
2018 ..Default::default()
2019 },
2020 )
2021 .unwrap();
2022 client.conn.execute(
2023 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
2024 rusqlite::params![reference, reference, reference, reference],
2025 ).unwrap();
2026 client
2027 .conn
2028 .execute(
2029 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
2030 rusqlite::params![reference],
2031 )
2032 .unwrap();
2033 client.conn.execute(
2034 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
2035 [],
2036 ).unwrap();
2037 let mut transport = CountingRealtimeTransport {
2038 blob_download: Some(bytes.clone()),
2039 ..Default::default()
2040 };
2041
2042 assert_eq!(
2043 client
2044 .fetch_blob_bytes(&mut transport, &blob_id)
2045 .unwrap()
2046 .bytes,
2047 bytes
2048 );
2049 let rows = client
2050 .conn
2051 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
2052 .unwrap()
2053 .query_map([], |row| row.get::<_, String>(0))
2054 .unwrap()
2055 .collect::<Result<Vec<_>, _>>()
2056 .unwrap();
2057 assert_eq!(rows, vec![blob_id]);
2058 }
2059
2060 #[test]
2061 fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
2062 let mut transport = CountingRealtimeTransport::default();
2063 let path = std::env::temp_dir().join(format!(
2064 "syncular-transport-gate-{}.db",
2065 uuid::Uuid::new_v4()
2066 ));
2067 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2068 {"name":"id","type":"string","nullable":false},
2069 {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
2070 let mut client = SyncClient::open_path(
2071 "gate".into(),
2072 &schema,
2073 ClientLimits::default(),
2074 path.to_str().unwrap(),
2075 )
2076 .unwrap();
2077 client.connect_realtime(&mut transport).unwrap();
2078 client.set_transport_enabled(&mut transport, false);
2079 assert_eq!(transport.closes, 1);
2080 client.begin_security_preflight();
2081 client.activate_security(Default::default()).unwrap();
2082 client
2083 .subscribe(
2084 "tasks".into(),
2085 "tasks".into(),
2086 vec![("project_id".into(), vec!["p1".into()])],
2087 None,
2088 )
2089 .unwrap();
2090 for id in ["one", "two"] {
2091 client
2092 .mutate(vec![Mutation::Upsert {
2093 table: "tasks".into(),
2094 values: Map::from_iter([
2095 ("id".into(), json!(id)),
2096 ("project_id".into(), json!("p1")),
2097 ]),
2098 base_version: None,
2099 }])
2100 .unwrap();
2101 }
2102 assert_eq!(
2103 client
2104 .query("SELECT id FROM tasks ORDER BY id", &[])
2105 .unwrap()
2106 .len(),
2107 2
2108 );
2109 assert!(
2110 matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
2111 );
2112 assert!(client
2113 .connect_realtime(&mut transport)
2114 .unwrap_err()
2115 .starts_with("sync.offline:"));
2116 assert!(client
2117 .set_presence(&mut transport, "project:p1", None)
2118 .unwrap_err()
2119 .starts_with("sync.offline:"));
2120 assert_eq!(transport.sync_calls, 0);
2121 assert_eq!(transport.realtime_sync_calls, 0);
2122 assert_eq!(transport.connects, 1);
2123 assert!(transport.messages.is_empty());
2124 assert!(matches!(
2125 client.drain_sync_intents().as_slice(),
2126 [SyncIntent::None]
2127 ));
2128 let pending = client.pending_commit_ids();
2129 drop(client);
2130 let mut reopened = SyncClient::open_path(
2131 "gate".into(),
2132 &schema,
2133 ClientLimits::default(),
2134 path.to_str().unwrap(),
2135 )
2136 .unwrap();
2137 assert!(reopened.transport_enabled());
2138 reopened.set_transport_enabled(&mut transport, false);
2139 assert_eq!(reopened.pending_commit_ids(), pending);
2140 assert_eq!(
2141 reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
2142 2
2143 );
2144 reopened.set_transport_enabled(&mut transport, true);
2145 assert!(matches!(
2146 reopened.drain_sync_intents().as_slice(),
2147 [SyncIntent::Interactive]
2148 ));
2149 reopened.set_transport_enabled(&mut transport, true);
2150 assert!(reopened.drain_sync_intents().is_empty());
2151 drop(reopened);
2152 std::fs::remove_file(path).unwrap();
2153 }
2154
2155 #[test]
2156 fn realtime_connection_ownership_is_idempotent() {
2157 let mut client = client();
2158 let mut transport = CountingRealtimeTransport {
2159 connects: 0,
2160 closes: 0,
2161 ..Default::default()
2162 };
2163 client
2164 .connect_realtime(&mut transport)
2165 .expect("first connect");
2166 client
2167 .connect_realtime(&mut transport)
2168 .expect("idempotent connect");
2169 assert_eq!(transport.connects, 1);
2170 client.disconnect_realtime(&mut transport);
2171 client.disconnect_realtime(&mut transport);
2172 assert_eq!(transport.closes, 1);
2173 client
2174 .connect_realtime(&mut transport)
2175 .expect("deliberate reconnect");
2176 assert_eq!(transport.connects, 2);
2177 }
2178
2179 #[test]
2180 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
2181 let mut client = client();
2182 client
2183 .set_realtime_policy(RealtimePolicy::Required)
2184 .expect("set policy");
2185 let mut transport = CountingRealtimeTransport::default();
2186 match client.sync(&mut transport) {
2187 SyncOutcome::RealtimeUnavailable {
2188 state,
2189 reason_code,
2190 retry_delay_ms,
2191 } => {
2192 assert_eq!(state, RealtimeState::Disconnected);
2193 assert_eq!(reason_code, None);
2194 assert_eq!(retry_delay_ms, 250);
2195 }
2196 other => panic!("expected RealtimeUnavailable, got {other:?}"),
2197 }
2198 assert_eq!(transport.sync_calls, 0);
2199 assert!(matches!(
2200 client.drain_sync_intents().as_slice(),
2201 [SyncIntent::Background { delay_ms: 250 }]
2202 ));
2203 let snapshot = client
2204 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2205 .expect("diagnostics");
2206 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
2207 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
2208 assert_eq!(snapshot.host.realtime_reason_code, None);
2209 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
2210 assert_eq!(
2211 snapshot
2212 .last_round
2213 .as_ref()
2214 .and_then(|round| round.error_code.clone()),
2215 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
2216 );
2217 }
2218
2219 #[test]
2220 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
2221 let mut client = client();
2222 client
2223 .set_realtime_policy(RealtimePolicy::Required)
2224 .expect("set policy");
2225 let mut transport = CountingRealtimeTransport {
2226 refuse_connect: true,
2227 ..Default::default()
2228 };
2229 assert!(client.connect_realtime(&mut transport).is_err());
2230 assert_eq!(client.realtime_state(), RealtimeState::Refused);
2231 let snapshot = client
2232 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2233 .expect("diagnostics");
2234 assert_eq!(
2235 snapshot.host.realtime_reason_code.as_deref(),
2236 Some("transport.failed")
2237 );
2238 match client.sync(&mut transport) {
2239 SyncOutcome::RealtimeUnavailable {
2240 state, reason_code, ..
2241 } => {
2242 assert_eq!(state, RealtimeState::Refused);
2243 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
2244 }
2245 other => panic!("expected RealtimeUnavailable, got {other:?}"),
2246 }
2247 assert_eq!(transport.sync_calls, 0);
2248 assert_eq!(transport.realtime_sync_calls, 0);
2249 }
2250
2251 #[test]
2252 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
2253 let mut client = client();
2254 client
2255 .set_realtime_policy(RealtimePolicy::Required)
2256 .expect("set policy");
2257 let mut transport = CountingRealtimeTransport::default();
2258 client.connect_realtime(&mut transport).expect("connect");
2259 assert_eq!(client.realtime_state(), RealtimeState::Connected);
2260 assert!(
2261 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
2262 "a policy change cannot silently re-route a connected socket"
2263 );
2264 assert!(matches!(
2265 client.sync(&mut transport),
2266 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
2267 ));
2268 assert_eq!(client.realtime_state(), RealtimeState::Lost);
2269 assert_eq!(transport.realtime_sync_calls, 1);
2270 assert_eq!(transport.sync_calls, 0);
2271 let snapshot = client
2272 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2273 .expect("diagnostics");
2274 assert_eq!(
2275 snapshot.host.realtime_reason_code.as_deref(),
2276 Some("sync.transport_failed")
2277 );
2278 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
2279 match client.sync(&mut transport) {
2280 SyncOutcome::RealtimeUnavailable {
2281 state, reason_code, ..
2282 } => {
2283 assert_eq!(state, RealtimeState::Lost);
2284 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
2285 }
2286 other => panic!("expected RealtimeUnavailable, got {other:?}"),
2287 }
2288 assert_eq!(transport.sync_calls, 0);
2289 client.disconnect_realtime(&mut transport);
2290 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
2291 assert_eq!(transport.closes, 1);
2292 client.connect_realtime(&mut transport).expect("reconnect");
2293 assert_eq!(client.realtime_state(), RealtimeState::Connected);
2294 assert_eq!(transport.connects, 2);
2295 }
2296
2297 #[test]
2298 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
2299 let mut client = client();
2300 client
2301 .set_realtime_policy(RealtimePolicy::Off)
2302 .expect("set policy");
2303 let mut transport = CountingRealtimeTransport::default();
2304 assert!(client.connect_realtime(&mut transport).is_err());
2305 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
2306 assert!(matches!(
2307 client.sync(&mut transport),
2308 SyncOutcome::Failed { .. }
2309 ));
2310 assert_eq!(transport.sync_calls, 1);
2311 assert_eq!(transport.realtime_sync_calls, 0);
2312 let snapshot = client
2313 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2314 .expect("diagnostics");
2315 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
2316 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
2317 }
2318
2319 #[test]
2320 fn optional_policy_keeps_the_http_round() {
2321 let mut client = client();
2322 let mut transport = CountingRealtimeTransport::default();
2323 assert!(matches!(
2324 client.sync(&mut transport),
2325 SyncOutcome::Failed { .. }
2326 ));
2327 assert_eq!(transport.sync_calls, 1);
2328 assert_eq!(transport.realtime_sync_calls, 0);
2329 let snapshot = client
2330 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2331 .expect("diagnostics");
2332 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
2333 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
2334 }
2335
2336 #[test]
2337 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
2338 let mut client = client();
2339 client
2340 .subscribe(
2341 "repair-tasks".to_owned(),
2342 "tasks".to_owned(),
2343 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2344 None,
2345 )
2346 .expect("subscribe");
2347 {
2348 let sub = client
2349 .subs
2350 .iter_mut()
2351 .find(|sub| sub.id == "repair-tasks")
2352 .expect("subscription");
2353 sub.cursor = 42;
2354 sub.synced_once = true;
2355 let persisted = sub.clone();
2356 client.persist_sub(&persisted).unwrap();
2357 }
2358 for table in ["_syncular_base_tasks", "tasks"] {
2359 client
2360 .conn
2361 .execute(
2362 &format!(
2363 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
2364 ),
2365 rusqlite::params!["server-row", "p1"],
2366 )
2367 .expect("seed server row");
2368 }
2369 let pending = client
2370 .mutate(vec![Mutation::Upsert {
2371 table: "tasks".to_owned(),
2372 values: Map::from_iter([
2373 ("id".to_owned(), Value::from("offline-row")),
2374 ("project_id".to_owned(), Value::from("p1")),
2375 ]),
2376 base_version: None,
2377 }])
2378 .expect("queue offline work");
2379 client.drain_change_batches();
2380 client.drain_sync_intents();
2381
2382 assert_eq!(
2383 client
2384 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2385 rebootstrap_id: "support-case-001".to_owned(),
2386 })
2387 .expect("rebootstrap"),
2388 LocalDataRebootstrapResult {
2389 already_applied: false,
2390 retained_commits: 1,
2391 reset_subscriptions: 1,
2392 }
2393 );
2394 let visible_ids = client
2395 .conn
2396 .prepare("SELECT id FROM tasks ORDER BY id")
2397 .expect("prepare visible ids")
2398 .query_map([], |row| row.get::<_, String>(0))
2399 .expect("query visible ids")
2400 .collect::<Result<Vec<_>, _>>()
2401 .expect("collect visible ids");
2402 assert_eq!(visible_ids, vec!["offline-row"]);
2403 assert_eq!(client.pending_commit_ids(), vec![pending]);
2404 assert_eq!(
2405 client
2406 .subscription_state("repair-tasks")
2407 .expect("subscription")
2408 .cursor,
2409 -1
2410 );
2411 assert!(client.upgrading());
2412 assert!(client.sync_needed());
2413 assert!(matches!(
2414 client.drain_sync_intents().as_slice(),
2415 [SyncIntent::Interactive]
2416 ));
2417 assert_eq!(client.drain_change_batches().len(), 1);
2418
2419 assert_eq!(
2420 client
2421 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2422 rebootstrap_id: "support-case-001".to_owned(),
2423 })
2424 .expect("idempotent retry"),
2425 LocalDataRebootstrapResult {
2426 already_applied: true,
2427 retained_commits: 1,
2428 reset_subscriptions: 1,
2429 }
2430 );
2431 assert!(client.drain_change_batches().is_empty());
2432 }
2433
2434 #[test]
2435 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
2436 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
2437 assert_eq!(
2438 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
2439 (3, 4)
2440 );
2441 assert_eq!(
2442 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
2443 (0, 0)
2444 );
2445 for malformed in [
2446 "",
2447 "{}",
2448 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
2449 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
2450 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
2451 ] {
2452 assert_eq!(
2453 decode_local_rebootstrap_receipt(malformed)
2454 .expect_err("malformed receipt must fail"),
2455 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
2456 );
2457 }
2458 }
2459
2460 #[test]
2461 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
2462 let path = std::env::temp_dir().join(format!(
2463 "syncular-rebootstrap-receipt-{}.db",
2464 uuid::Uuid::new_v4()
2465 ));
2466 let schema = json!({
2467 "version": 1,
2468 "tables": [{
2469 "name": "tasks",
2470 "primaryKey": "id",
2471 "columns": [
2472 { "name": "id", "type": "string", "nullable": false },
2473 { "name": "project_id", "type": "string", "nullable": false }
2474 ],
2475 "scopes": [{ "pattern": "project:{project_id}" }]
2476 }]
2477 });
2478 let path_string = path.to_str().expect("UTF-8 temp path");
2479
2480 {
2481 let mut first = SyncClient::open_path(
2482 "repair-restart-client".to_owned(),
2483 &schema,
2484 ClientLimits::default(),
2485 path_string,
2486 )
2487 .expect("first open");
2488 first
2489 .subscribe(
2490 "repair-tasks".to_owned(),
2491 "tasks".to_owned(),
2492 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2493 None,
2494 )
2495 .expect("subscribe");
2496 first
2497 .mutate(vec![Mutation::Upsert {
2498 table: "tasks".to_owned(),
2499 values: Map::from_iter([
2500 ("id".to_owned(), Value::from("offline-row")),
2501 ("project_id".to_owned(), Value::from("p1")),
2502 ]),
2503 base_version: None,
2504 }])
2505 .expect("queue offline work");
2506 assert_eq!(
2507 first
2508 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2509 rebootstrap_id: "restart-receipt".to_owned(),
2510 })
2511 .expect("first rebootstrap"),
2512 LocalDataRebootstrapResult {
2513 already_applied: false,
2514 retained_commits: 1,
2515 reset_subscriptions: 1,
2516 }
2517 );
2518 }
2519
2520 let mut reopened = SyncClient::open_path(
2521 "repair-restart-client".to_owned(),
2522 &schema,
2523 ClientLimits::default(),
2524 path_string,
2525 )
2526 .expect("reopen");
2527 reopened.drain_change_batches();
2528 reopened.drain_sync_intents();
2529 assert_eq!(
2530 reopened
2531 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2532 rebootstrap_id: "restart-receipt".to_owned(),
2533 })
2534 .expect("receipt replay"),
2535 LocalDataRebootstrapResult {
2536 already_applied: true,
2537 retained_commits: 1,
2538 reset_subscriptions: 1,
2539 }
2540 );
2541 assert!(reopened.drain_change_batches().is_empty());
2542 assert!(reopened.drain_sync_intents().is_empty());
2543 drop(reopened);
2544 std::fs::remove_file(path).expect("remove temp database");
2545 }
2546
2547 #[test]
2548 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
2549 let mut malformed = client();
2550 malformed
2551 .subscribe(
2552 "repair-tasks".to_owned(),
2553 "tasks".to_owned(),
2554 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2555 None,
2556 )
2557 .expect("subscribe");
2558 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
2559 malformed.drain_change_batches();
2560 malformed.drain_sync_intents();
2561 let malformed_error = malformed
2562 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2563 rebootstrap_id: "malformed".to_owned(),
2564 })
2565 .expect_err("malformed receipt must fail closed");
2566 assert_eq!(
2567 malformed_error,
2568 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
2569 );
2570 assert!(!malformed.upgrading());
2571 assert_eq!(
2572 malformed
2573 .subscription_state("repair-tasks")
2574 .expect("unchanged subscription")
2575 .cursor,
2576 -1
2577 );
2578 assert!(malformed.drain_change_batches().is_empty());
2579 assert!(malformed.drain_sync_intents().is_empty());
2580
2581 let mut unreadable = client();
2582 unreadable
2583 .conn
2584 .execute(
2585 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
2586 rusqlite::params!["server-row", "p1"],
2587 )
2588 .expect("seed visible row");
2589 unreadable
2590 .conn
2591 .execute("DROP TABLE _syncular_meta", [])
2592 .expect("break marker storage");
2593 let unreadable_error = unreadable
2594 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2595 rebootstrap_id: "unreadable".to_owned(),
2596 })
2597 .expect_err("unreadable marker storage must fail closed");
2598 assert_eq!(
2599 unreadable_error,
2600 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
2601 );
2602 let visible_rows = unreadable
2603 .conn
2604 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
2605 .expect("visible projection remains");
2606 assert_eq!(visible_rows, 1);
2607 assert!(!unreadable.upgrading());
2608 assert!(unreadable.drain_change_batches().is_empty());
2609 assert!(unreadable.drain_sync_intents().is_empty());
2610 }
2611
2612 #[test]
2613 fn local_rebootstrap_cannot_bypass_schema_floor() {
2614 let mut client = client();
2615 client.set_schema_floor(Some(SchemaFloor {
2616 required_schema_version: Some(2),
2617 latest_schema_version: Some(2),
2618 }));
2619 let error = client
2620 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2621 rebootstrap_id: "blocked-floor".to_owned(),
2622 })
2623 .expect_err("schema floor must block repair");
2624 assert!(error.contains("cannot bypass an active schema-floor stop"));
2625 }
2626
2627 #[test]
2628 fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
2629 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2630 "columns":[{"name":"id","type":"string","nullable":false},
2631 {"name":"project_id","type":"string","nullable":false},
2632 {"name":"starts_at_ms","type":"integer","nullable":false},
2633 {"name":"version","type":"integer","nullable":false}],
2634 "scopes":[{"pattern":"project:{project_id}"}]}]});
2635 let mut client =
2636 SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
2637 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2638 client
2639 .write_base_row(
2640 "tasks",
2641 &vec![
2642 Some(ColumnValue::String("t1".into())),
2643 Some(ColumnValue::String("p1".into())),
2644 Some(ColumnValue::Integer(100)),
2645 Some(ColumnValue::Integer(1)),
2646 ],
2647 1,
2648 )
2649 .unwrap();
2650 client.rebuild_overlay().expect("rebuild overlay");
2651 let first = client
2652 .patch(
2653 "tasks",
2654 "t1",
2655 Map::from_iter([
2656 ("starts_at_ms".into(), json!(200)),
2657 ("version".into(), json!(2)),
2658 ]),
2659 None,
2660 )
2661 .unwrap();
2662 let (_, first_request) = client.build_request(false).unwrap();
2663 let second = client
2665 .patch(
2666 "tasks",
2667 "t1",
2668 Map::from_iter([
2669 ("starts_at_ms".into(), json!(300)),
2670 ("version".into(), json!(3)),
2671 ]),
2672 None,
2673 )
2674 .unwrap();
2675 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2676 for (id, seq, meta) in [
2677 (first, 2, first_request),
2678 (second, 3, client.build_request(false).unwrap().1),
2679 ] {
2680 let response = Message {
2681 wire_version: WIRE_VERSION,
2682 msg_kind: MsgKind::Response,
2683 frames: vec![
2684 Frame::RespHeader {
2685 required_schema_version: None,
2686 latest_schema_version: None,
2687 log_epoch: Some("epoch-1".into()),
2688 reset_required: Some(false),
2689 },
2690 Frame::PushResult {
2691 client_commit_id: id,
2692 status: PushStatus::Applied,
2693 commit_seq: Some(seq),
2694 results: vec![OpResult::Applied { op_index: 0 }],
2695 },
2696 ],
2697 };
2698 assert!(matches!(
2699 client.process_response(&mut transport, response, &meta),
2700 SyncOutcome::Ok(_)
2701 ));
2702 let row = &client
2703 .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
2704 .unwrap()[0];
2705 assert_eq!(
2706 row["starts_at_ms"],
2707 json!(300),
2708 "an applied ACK without a pull image must not expose the old base"
2709 );
2710 assert_eq!(
2711 row["version"],
2712 json!(3),
2713 "the queued domain version must remain monotonic"
2714 );
2715 }
2716 }
2717
2718 #[test]
2719 fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
2720 for stored in [None, Some("epoch-old")] {
2721 let mut client = client();
2722 client
2723 .subscribe(
2724 "tasks".into(),
2725 "tasks".into(),
2726 vec![("project_id".into(), vec!["p1".into()])],
2727 None,
2728 )
2729 .unwrap();
2730 client
2731 .conn
2732 .execute_batch(
2733 "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
2734 )
2735 .unwrap();
2736 let id = client
2737 .mutate(vec![Mutation::Upsert {
2738 table: "tasks".into(),
2739 values: Map::from_iter([
2740 ("id".into(), json!("offline")),
2741 ("project_id".into(), json!("p1")),
2742 ]),
2743 base_version: None,
2744 }])
2745 .unwrap();
2746 if let Some(epoch) = stored {
2747 client.set_meta(LOG_EPOCH_KEY, epoch);
2748 }
2749 let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
2750 let (_, meta) = client.build_request(false).unwrap();
2751 client.drain_change_batches();
2752 client.drain_sync_intents();
2753 let response = Message {
2754 wire_version: WIRE_VERSION,
2755 msg_kind: MsgKind::Response,
2756 frames: vec![Frame::RespHeader {
2757 required_schema_version: None,
2758 latest_schema_version: None,
2759 log_epoch: Some("epoch-new".into()),
2760 reset_required: Some(true),
2761 }],
2762 };
2763 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2764 let outcome = client.process_response(&mut transport, response, &meta);
2765 let SyncOutcome::Ok(report) = outcome else {
2766 panic!("epoch round failed: {outcome:?}");
2767 };
2768 assert_eq!(
2769 report.resets,
2770 if stored.is_none() {
2771 Vec::<String>::new()
2772 } else {
2773 vec!["tasks".into()]
2774 }
2775 );
2776 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
2777 assert_eq!(client.pending_commit_ids(), vec![id]);
2778 assert_eq!(
2779 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2780 json!("offline")
2781 );
2782 let trigger: i64 = client
2783 .conn
2784 .query_row(
2785 "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
2786 [],
2787 |row| row.get(0),
2788 )
2789 .unwrap();
2790 if stored.is_none() {
2791 assert!(!client.upgrading);
2792 assert!(client
2793 .drain_change_batches()
2794 .iter()
2795 .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
2796 assert_eq!(
2797 serde_json::to_value(client.subscription_state("tasks")).unwrap(),
2798 before
2799 );
2800 assert_eq!(trigger, 1, "acquisition must not drop the synced table");
2801 } else {
2802 assert!(client.upgrading);
2803 assert_eq!(trigger, 0, "an epoch change must drop the old table");
2804 }
2805 assert!(matches!(
2806 client.drain_sync_intents().as_slice(),
2807 [SyncIntent::Interactive]
2808 ));
2809 }
2810 }
2811
2812 #[test]
2813 fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
2814 for key in ["logEpoch", "localRevision"] {
2815 let mut client = client();
2816 let id = client
2817 .mutate(vec![Mutation::Upsert {
2818 table: "tasks".into(),
2819 values: Map::from_iter([
2820 ("id".into(), json!("offline")),
2821 ("project_id".into(), json!("p1")),
2822 ]),
2823 base_version: None,
2824 }])
2825 .unwrap();
2826 let revision = client.local_revision();
2827 let needed = client.sync_needed;
2828 client.drain_change_batches();
2829 client.drain_sync_intents();
2830 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();
2831 assert!(client.run_log_epoch_reset("epoch-new").is_err());
2832 assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
2833 assert!(!client.upgrading);
2834 assert_eq!(client.sync_needed, needed);
2835 assert_eq!(client.local_revision(), revision);
2836 assert_eq!(client.pending_commit_ids(), [id]);
2837 assert!(client.drain_change_batches().is_empty());
2838 assert!(client.drain_sync_intents().is_empty());
2839 }
2840 }
2841
2842 #[test]
2843 fn failed_log_epoch_resets_restore_memory_and_durable_state() {
2844 for key in [
2845 "localSchemaVersion",
2846 "localSchemaDescriptor",
2847 "logEpoch",
2848 "localRevision",
2849 ] {
2850 let mut client = client();
2851 client.set_meta(LOG_EPOCH_KEY, "epoch-old");
2852 let commit = client
2853 .mutate(vec![Mutation::Upsert {
2854 table: "tasks".into(),
2855 values: Map::from_iter([
2856 ("id".into(), json!("offline")),
2857 ("project_id".into(), json!("p1")),
2858 ]),
2859 base_version: None,
2860 }])
2861 .unwrap();
2862 client.active_round = Some(uuid::Uuid::new_v4());
2863 client.overlay_dirty.set(false);
2864 let round = client.active_round;
2865 let needed = client.sync_needed;
2866 let revision = client.local_revision();
2867 let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
2868 client.drain_change_batches();
2869 client.drain_sync_intents();
2870 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();
2871 assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
2872 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
2873 assert!(!client.upgrading);
2874 assert_eq!(client.active_round, round);
2875 assert!(!client.overlay_dirty.get());
2876 assert_eq!(client.sync_needed, needed);
2877 assert_eq!(client.local_revision(), revision);
2878 assert_eq!(
2879 client.pending_commit_ids().as_slice(),
2880 std::slice::from_ref(&commit)
2881 );
2882 assert_eq!(
2883 serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
2884 rows
2885 );
2886 assert!(client.drain_change_batches().is_empty());
2887 assert!(client.drain_sync_intents().is_empty());
2888 assert!(client.conn.is_autocommit());
2889 client
2890 .conn
2891 .execute_batch("DROP TRIGGER fail_reset")
2892 .unwrap();
2893 client.run_log_epoch_reset("epoch-new").unwrap();
2894 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
2895 assert_eq!(client.pending_commit_ids(), [commit]);
2896 }
2897 }
2898
2899 #[test]
2900 fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
2901 for subscriptions in [0, 2, 6, 12] {
2902 let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
2903 { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
2904 { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
2905 ] }), ClientLimits::default()).unwrap();
2906 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2907 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();
2908 for index in 0..32 {
2909 client
2910 .mutate(vec![Mutation::Upsert {
2911 table: "tasks".into(),
2912 values: Map::from_iter([
2913 ("id".into(), json!(format!("task-{index}"))),
2914 ("project_id".into(), json!("p1")),
2915 ]),
2916 base_version: None,
2917 }])
2918 .unwrap();
2919 }
2920 let (_, meta) = client.build_request(false).unwrap();
2921 let mut frames = vec![Frame::RespHeader {
2922 required_schema_version: None,
2923 latest_schema_version: None,
2924 log_epoch: Some("epoch-1".into()),
2925 reset_required: Some(false),
2926 }];
2927 frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
2928 Frame::PushResult {
2929 client_commit_id: id.clone(),
2930 status: PushStatus::Applied,
2931 commit_seq: Some(index as i64 + 1),
2932 results: vec![OpResult::Applied { op_index: 0 }],
2933 }
2934 }));
2935 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2936 client.acknowledged_replay_count.set(0);
2937 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
2938 assert!(matches!(
2939 client.process_response(
2940 &mut transport,
2941 Message {
2942 wire_version: WIRE_VERSION,
2943 msg_kind: MsgKind::Response,
2944 frames
2945 },
2946 &meta
2947 ),
2948 SyncOutcome::Ok(_)
2949 ));
2950 assert_eq!(client.acknowledged_replay_count.get(), 32);
2951 assert_eq!(
2952 client
2953 .query(
2954 "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
2955 &[]
2956 )
2957 .unwrap()[0]["n"],
2958 json!(32)
2959 );
2960 assert_eq!(
2961 client
2962 .query(
2963 "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
2964 &[]
2965 )
2966 .unwrap()[0]["n"],
2967 json!(32)
2968 );
2969 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
2970 client.acknowledged_replay_count.set(0);
2971 for index in 0..subscriptions {
2972 client
2973 .subscribe(
2974 format!("docs-{index}"),
2975 "docs".into(),
2976 vec![("project_id".into(), vec![format!("scope-{index}")])],
2977 None,
2978 )
2979 .unwrap();
2980 let sub = client.subs.len() - 1;
2981 client.subs[sub].effective =
2982 Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
2983 let segment = RowsSegment {
2984 table: "docs".into(),
2985 schema_version: 1,
2986 columns: client.schema.table("docs").unwrap().wire_columns.clone(),
2987 blocks: if index % 2 == 0 {
2988 vec![vec![ssp2::segment::SegmentRow {
2989 values: vec![
2990 Some(ColumnValue::String(format!("doc-{index}"))),
2991 Some(ColumnValue::String(format!("scope-{index}"))),
2992 ],
2993 server_version: 1,
2994 }]]
2995 } else {
2996 vec![]
2997 },
2998 };
2999 assert!(client.apply_segment(sub, &segment, true).is_ok());
3000 }
3001 assert_eq!(
3002 client
3003 .query("SELECT count(*) AS n FROM ack_writes", &[])
3004 .unwrap()[0]["n"],
3005 json!(0),
3006 "unrelated bootstrap must not restore/replay tasks"
3007 );
3008 assert_eq!(
3009 client.acknowledged_replay_count.get(),
3010 0,
3011 "unrelated bootstrap must not decode task ACKs"
3012 );
3013 assert_eq!(
3014 client
3015 .query("SELECT count(*) AS n FROM tasks", &[])
3016 .unwrap()[0]["n"],
3017 json!(32)
3018 );
3019 assert_eq!(
3020 client
3021 .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
3022 .unwrap()[0]["n"],
3023 json!(32)
3024 );
3025 }
3026 }
3027
3028 #[test]
3029 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
3030 let mut client = client();
3031 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3032 const COMMIT_COUNT: usize = 32;
3033
3034 for index in 0..COMMIT_COUNT {
3035 client
3036 .mutate(vec![Mutation::Upsert {
3037 table: "tasks".to_owned(),
3038 values: Map::from_iter([
3039 ("id".to_owned(), Value::from(format!("task-{index}"))),
3040 ("project_id".to_owned(), Value::from("project-1")),
3041 ]),
3042 base_version: None,
3043 }])
3044 .expect("queue commit");
3045 }
3046
3047 let (_, request_meta) = client.build_request(false).unwrap();
3048 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
3049 let mut frames = vec![Frame::RespHeader {
3050 required_schema_version: None,
3051 latest_schema_version: None,
3052 log_epoch: Some("epoch-1".to_owned()),
3053 reset_required: Some(false),
3054 }];
3055 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
3056 |(index, client_commit_id)| Frame::PushResult {
3057 client_commit_id: client_commit_id.clone(),
3058 status: PushStatus::Applied,
3059 commit_seq: Some(index as i64 + 1),
3060 results: vec![OpResult::Applied { op_index: 0 }],
3061 },
3062 ));
3063 let response = Message {
3064 wire_version: WIRE_VERSION,
3065 msg_kind: MsgKind::Response,
3066 frames,
3067 };
3068 let mut transport =
3069 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3070
3071 client.overlay_rebuild_count.set(0);
3072 client.outcome_prune_count.set(0);
3073 let outcome = client.process_response(&mut transport, response, &request_meta);
3074 assert!(matches!(outcome, SyncOutcome::Ok(_)));
3075 assert!(client.pending_commit_ids().is_empty());
3076 assert_eq!(
3077 client.overlay_rebuild_count.get(),
3078 1,
3079 "one response must reconcile its acknowledged commits with one overlay rebuild"
3080 );
3081 assert_eq!(
3082 client.outcome_prune_count.get(),
3083 1,
3084 "one response must enforce outcome retention once"
3085 );
3086 }
3087
3088 #[test]
3089 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
3090 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
3091 "columns": [
3092 {"name":"id", "type":"string", "nullable":false},
3093 {"name":"project_id", "type":"string", "nullable":false},
3094 {"name":"title", "type":"string", "nullable":false}
3095 ], "scopes": [{"pattern":"project:{project_id}"}],
3096 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
3097 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
3098 }] });
3099 let mut incremental =
3100 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3101 let mut reference =
3102 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3103 let tables = vec!["tasks".to_owned()];
3104 for index in 0..32 {
3105 let id = format!("task-{}", index % 8);
3106 let values = Map::from_iter([
3107 ("id".to_owned(), Value::from(id.clone())),
3108 (
3109 "project_id".to_owned(),
3110 Value::from(format!("p{}", index % 2)),
3111 ),
3112 ("title".to_owned(), Value::from(format!("needle{index}"))),
3113 ]);
3114 let payload = encode_row_json(
3115 incremental.schema.table("tasks").unwrap(),
3116 &id,
3117 &values,
3118 &incremental.encryption,
3119 )
3120 .unwrap();
3121 let change = ssp2::model::Change {
3122 table_index: 0,
3123 row_id: id,
3124 op: if index % 5 == 0 {
3125 Op::Delete
3126 } else {
3127 Op::Upsert
3128 },
3129 row_version: Some(index + 1),
3130 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
3131 row: Some(payload),
3132 };
3133 reference.overlay_dirty.set(true);
3134 for instance in [&mut incremental, &mut reference] {
3135 instance.begin_observation("mirror_test").unwrap();
3136 let mut batch = ChangeAccumulator::default();
3137 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
3138 instance
3139 .apply_commit_changes(&tables, std::slice::from_ref(&change))
3140 .unwrap();
3141 instance
3142 .rebuild_overlay_if_dirty()
3143 .expect("rebuild overlay");
3144 instance.finish_observation("mirror_test", batch).unwrap();
3145 }
3146 for sql in [
3147 "SELECT * FROM tasks ORDER BY id",
3148 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
3149 ] {
3150 assert_eq!(
3151 incremental.query(sql, &[]).unwrap(),
3152 reference.query(sql, &[]).unwrap()
3153 );
3154 }
3155 assert_eq!(incremental.local_revision(), reference.local_revision());
3156 assert_eq!(
3157 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
3158 serde_json::to_value(reference.drain_change_batches()).unwrap()
3159 );
3160 }
3161 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
3162 assert_eq!(reference.overlay_rebuild_count.get(), 32);
3163 }
3164
3165 #[test]
3166 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
3167 let cases = [
3168 (
3169 "string",
3170 vec![
3171 json!("1"),
3172 json!("01"),
3173 json!(""),
3174 json!("nul\u{0}id"),
3175 json!("é"),
3176 ],
3177 ),
3178 (
3179 "integer",
3180 vec![
3181 json!(i64::MIN),
3182 json!(-1),
3183 json!(0),
3184 json!(1),
3185 json!(i64::MAX),
3186 ],
3187 ),
3188 ("boolean", vec![json!(false), json!(true)]),
3189 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
3190 ];
3191 for (kind, ids) in cases {
3192 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3193 "columns":[{"name":"id","type":kind,"nullable":false},
3194 {"name":"project_id","type":"string","nullable":false}],
3195 "scopes":[{"pattern":"project:{project_id}"}]}]});
3196 let mut client =
3197 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
3198 for (index, id) in ids.iter().enumerate() {
3199 let table = client.schema.table("tasks").unwrap();
3200 client
3201 .write_base_row(
3202 "tasks",
3203 &vec![
3204 if kind == "integer" {
3205 Some(ColumnValue::Integer(id.as_i64().unwrap()))
3208 } else {
3209 json_to_column_value(&table.columns[0], Some(id)).unwrap()
3210 },
3211 Some(ColumnValue::String(format!("p{index}"))),
3212 ],
3213 1,
3214 )
3215 .unwrap();
3216 }
3217 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3218 for base in [true, false] {
3219 let full = if base {
3220 base_table("tasks")
3221 } else {
3222 visible_table("tasks")
3223 };
3224 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
3225 for row_id in [
3226 "1",
3227 "01",
3228 "1.0",
3229 "1.0000000000000002",
3230 "+1",
3231 "1e0",
3232 " 1",
3233 "-1",
3234 "0",
3235 "9223372036854775807",
3236 "-9223372036854775808",
3237 "true",
3238 "false",
3239 "",
3240 "nul\u{0}id",
3241 "é",
3242 "null",
3243 "{\"a\":1}",
3244 ] {
3245 let old_sql =
3246 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
3247 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
3248 let old = client
3249 .conn
3250 .prepare(&old_sql)
3251 .unwrap()
3252 .query_map([row_id], |row| row.get::<_, String>(0))
3253 .unwrap()
3254 .collect::<Result<Vec<_>, _>>()
3255 .unwrap();
3256 let new = client
3257 .conn
3258 .prepare_cached(&new_sql)
3259 .unwrap()
3260 .query_map([row_id], |row| row.get::<_, String>(0))
3261 .unwrap()
3262 .collect::<Result<Vec<_>, _>>()
3263 .unwrap();
3264 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
3265 let mut batch = ChangeAccumulator::default();
3266 assert_eq!(
3267 client.record_row_scopes(&mut batch, "tasks", row_id, base),
3268 !old.is_empty()
3269 );
3270 let expected = old.first().map(|scope| {
3271 BTreeMap::from([(
3272 "tasks".to_owned(),
3273 Some(BTreeSet::from([format!("project:{scope}")])),
3274 )])
3275 });
3276 assert_eq!(batch.tables, expected.unwrap_or_default());
3277 client
3278 .conn
3279 .execute_batch("SAVEPOINT lookup_delete")
3280 .unwrap();
3281 if base {
3282 client.delete_base_row("tasks", row_id).unwrap();
3283 } else {
3284 client
3285 .apply_outbox_ops(&[OutboxOp {
3286 upsert: false,
3287 table: "tasks".into(),
3288 row_id: row_id.into(),
3289 base_version: None,
3290 values: None,
3291 }])
3292 .expect("replay delete");
3293 }
3294 let remaining: i64 = client
3295 .conn
3296 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
3297 row.get(0)
3298 })
3299 .unwrap();
3300 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
3301 client
3302 .conn
3303 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
3304 .unwrap();
3305 client.overlay_dirty.set(false);
3306 }
3307 for (predicate, expected) in [
3308 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
3309 (predicate, "SEARCH"),
3310 ] {
3311 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
3312 let details = client
3313 .conn
3314 .prepare(&sql)
3315 .unwrap()
3316 .query_map(["1"], |row| row.get::<_, String>(3))
3317 .unwrap()
3318 .collect::<Result<Vec<_>, _>>()
3319 .unwrap();
3320 assert!(
3321 details.iter().any(|detail| detail.contains(expected)),
3322 "{kind}: {details:?}"
3323 );
3324 }
3325 }
3326 }
3327 }
3328
3329 #[test]
3330 fn primary_keys_reject_every_ineligible_column_type() {
3331 for ty in ["float", "bytes", "crdt", "blob_ref"] {
3335 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3336 "columns":[{"name":"id","type":ty,"nullable":false},
3337 {"name":"project_id","type":"string","nullable":false}],
3338 "scopes":[{"pattern":"project:{project_id}"}]}]});
3339 let error =
3340 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
3341 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
3342 Err(error) => error,
3343 };
3344 assert!(
3345 error.contains("has an ineligible column type (§2.4)"),
3346 "{ty}: {error}"
3347 );
3348 }
3349 for ty in ["string", "integer", "boolean", "json"] {
3350 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3351 "columns":[{"name":"id","type":ty,"nullable":false},
3352 {"name":"project_id","type":"string","nullable":false}],
3353 "scopes":[{"pattern":"project:{project_id}"}]}]});
3354 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
3355 .unwrap_or_else(|error| panic!("{ty}: {error}"));
3356 }
3357 }
3358
3359 #[test]
3360 fn pending_row_reconciliation_matches_full_fifo_replay() {
3361 for unique in [false, true] {
3362 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
3363 "name": name, "primaryKey": "id", "columns": [
3364 {"name":"id", "type":"string", "nullable":false},
3365 {"name":"project_id", "type":"string", "nullable":false},
3366 {"name":"title", "type":"string", "nullable":false}],
3367 "scopes": [{"pattern":"project:{project_id}"}],
3368 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
3369 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
3370 })))});
3371 let mut incremental =
3372 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
3373 let mut reference =
3374 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
3375 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
3376 for instance in [&mut incremental, &mut reference] {
3377 for table in &tables {
3378 for index in 0..12 {
3379 instance
3380 .write_base_row(
3381 table,
3382 &vec![
3383 Some(ColumnValue::String(format!("row{index}"))),
3384 Some(ColumnValue::String("p0".into())),
3385 Some(ColumnValue::String(format!("base{index}"))),
3386 ],
3387 1,
3388 )
3389 .unwrap();
3390 }
3391 }
3392 instance
3393 .rebuild_overlay_if_dirty()
3394 .expect("rebuild overlay");
3395 }
3396 for index in 0..80 {
3397 let mutations = (0..3)
3398 .map(|offset| {
3399 let table = tables[(index + offset) % 2].clone();
3400 let id = format!("row{}", (index + offset) % 16);
3401 if (index + offset) % 7 == 0 {
3402 Mutation::Delete {
3403 table,
3404 row_id: id,
3405 base_version: None,
3406 }
3407 } else {
3408 Mutation::Upsert {
3409 table,
3410 values: Map::from_iter([
3411 ("id".into(), Value::from(id)),
3412 ("project_id".into(), Value::from(format!("p{}", index % 3))),
3413 (
3414 "title".into(),
3415 Value::from(format!("needle{}", (index + offset) % 16)),
3416 ),
3417 ]),
3418 base_version: None,
3419 }
3420 }
3421 })
3422 .collect::<Vec<_>>();
3423 for instance in [&mut incremental, &mut reference] {
3424 instance.mutate(mutations.clone()).unwrap();
3425 }
3426 let changes = (0..3)
3429 .map(|offset| {
3430 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
3431 let id = format!("row{}", (index + offset) % 16);
3432 let values = Map::from_iter([
3433 ("id".into(), Value::from(id.clone())),
3434 (
3435 "project_id".into(),
3436 Value::from(format!("p{}", (index + 1) % 3)),
3437 ),
3438 (
3439 "title".into(),
3440 Value::from(format!("remote{index}_{offset}")),
3441 ),
3442 ]);
3443 ssp2::model::Change {
3444 table_index: table_index as u16,
3445 row_id: id.clone(),
3446 op: if (index + offset) % 5 == 0 {
3447 Op::Delete
3448 } else {
3449 Op::Upsert
3450 },
3451 row_version: Some(index as i64 + 2),
3452 scopes: vec![],
3453 row: Some(
3454 encode_row_json(
3455 incremental.schema.table(&tables[table_index]).unwrap(),
3456 &id,
3457 &values,
3458 &incremental.encryption,
3459 )
3460 .unwrap(),
3461 ),
3462 }
3463 })
3464 .collect::<Vec<_>>();
3465 reference.overlay_dirty.set(true);
3466 incremental
3467 .apply_commit_frame(&tables, &changes, None)
3468 .unwrap();
3469 reference
3470 .apply_commit_frame(&tables, &changes, None)
3471 .unwrap();
3472 for table in &tables {
3473 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
3474 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
3475 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
3476 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
3477 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
3478 }
3479 }
3480 assert_eq!(incremental.local_revision(), reference.local_revision());
3481 assert_eq!(
3482 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
3483 serde_json::to_value(reference.drain_change_batches()).unwrap()
3484 );
3485 }
3486 assert_eq!(
3487 incremental.overlay_rebuild_count.get(),
3488 if unique { 81 } else { 21 }
3489 );
3490 assert_eq!(reference.overlay_rebuild_count.get(), 81);
3491 }
3492 }
3493
3494 #[test]
3495 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
3496 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
3497 "columns":[{"name":"id", "type":"string", "nullable":false},
3498 {"name":"title", "type":"string", "nullable":false}],
3499 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
3500 let mut client =
3501 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
3502 for (id, title) in [("a", "original"), ("b", "occupied")] {
3503 client
3504 .write_base_row(
3505 "tasks",
3506 &vec![
3507 Some(ColumnValue::String(id.into())),
3508 Some(ColumnValue::String(title.into())),
3509 ],
3510 1,
3511 )
3512 .unwrap();
3513 }
3514 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3515 let pending = client
3516 .mutate(vec![Mutation::Upsert {
3517 table: "tasks".into(),
3518 values: Map::from_iter([
3519 ("id".into(), Value::from("a")),
3520 ("title".into(), Value::from("available")),
3521 ]),
3522 base_version: None,
3523 }])
3524 .unwrap();
3525 assert_eq!(
3526 client
3527 .conn
3528 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
3529 .get::<_, String>(
3530 0
3531 ))
3532 .unwrap(),
3533 "available"
3534 );
3535 for (title, expected) in [("available", "original"), ("occupied", "available")] {
3536 let change = ssp2::model::Change {
3537 table_index: 0,
3538 row_id: "b".into(),
3539 op: Op::Upsert,
3540 row_version: Some(2),
3541 scopes: vec![],
3542 row: Some(
3543 encode_row_json(
3544 client.schema.table("tasks").unwrap(),
3545 "b",
3546 &Map::from_iter([
3547 ("id".into(), Value::from("b")),
3548 ("title".into(), Value::from(title)),
3549 ]),
3550 &client.encryption,
3551 )
3552 .unwrap(),
3553 ),
3554 };
3555 client
3556 .apply_commit_frame(&["tasks".into()], &[change], None)
3557 .unwrap();
3558 assert_eq!(
3559 client
3560 .conn
3561 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
3562 .get::<_, String>(
3563 0
3564 ))
3565 .unwrap(),
3566 expected
3567 );
3568 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
3569 }
3570 assert_eq!(client.overlay_rebuild_count.get(), 3);
3571 }
3572
3573 fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
3574 let schema = json!({ "version": 1, "tables": [
3575 { "name":"tasks", "primaryKey":"id", "columns":[
3576 {"name":"id","type":"string","nullable":false},
3577 {"name":"project_id","type":"string","nullable":false}],
3578 "scopes":[{"pattern":"project:{project_id}"}] },
3579 { "name":"catalogue", "primaryKey":"id", "columns":[
3580 {"name":"id","type":"string","nullable":false},
3581 {"name":"project_id","type":"string","nullable":false},
3582 {"name":"title","type":"string","nullable":false}],
3583 "scopes":[{"pattern":"project:{project_id}"}],
3584 "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
3585 ] });
3586 let mut client =
3587 SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
3588 if unchanged_rows > 0 {
3589 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();
3590 }
3591 client.rebuild_overlay().expect("rebuild overlay");
3592 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();
3593 let mut elapsed = Vec::new();
3594 for round in 1..=rounds {
3595 let pending = client
3596 .mutate(vec![Mutation::Upsert {
3597 table: "tasks".into(),
3598 values: Map::from_iter([
3599 ("id".into(), json!("t1")),
3600 ("project_id".into(), json!("p1")),
3601 ]),
3602 base_version: None,
3603 }])
3604 .unwrap();
3605 let started = std::time::Instant::now();
3606 client
3607 .write_base_row(
3608 "tasks",
3609 &vec![
3610 Some(ColumnValue::String("t1".into())),
3611 Some(ColumnValue::String("p1".into())),
3612 ],
3613 round,
3614 )
3615 .unwrap();
3616 let mut report = SyncReport::default();
3617 client
3618 .handle_push_results(
3619 &[Frame::PushResult {
3620 client_commit_id: pending.clone(),
3621 status: PushStatus::Applied,
3622 commit_seq: Some(round),
3623 results: vec![OpResult::Applied { op_index: 0 }],
3624 }],
3625 &HashSet::from([pending.as_str()]),
3626 &HashMap::new(),
3627 &mut report,
3628 Some(&pending),
3629 )
3630 .unwrap();
3631 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3632 elapsed.push(started.elapsed().as_nanos());
3633 }
3634 assert_eq!(
3635 client
3636 .conn
3637 .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
3638 .unwrap(),
3639 0,
3640 "unchanged catalogue must never be copied"
3641 );
3642 assert_eq!(
3643 client
3644 .conn
3645 .query_row(
3646 "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
3647 [],
3648 |r| r.get::<_, i64>(0)
3649 )
3650 .unwrap(),
3651 unchanged_rows
3652 );
3653 assert_eq!(
3654 client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
3655 json!(rounds)
3656 );
3657 elapsed
3658 }
3659
3660 #[test]
3661 fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
3662 let elapsed = measure_scoped_overlay(100_000, 1);
3663 eprintln!(
3664 "table-scoped overlay with 100k unchanged rows: {} ns",
3665 elapsed[0]
3666 );
3667 }
3668
3669 #[test]
3670 #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
3671 fn benchmark_scoped_overlay() {
3672 for rows in [0, 100_000, 200_000] {
3673 let mut elapsed = measure_scoped_overlay(rows, 30);
3674 elapsed.sort_unstable();
3675 eprintln!(
3676 "{}",
3677 json!({"unchangedRows": rows, "rounds": elapsed.len(),
3678 "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
3679 "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
3680 );
3681 }
3682 }
3683
3684 #[test]
3685 fn released_context_discards_in_flight_ack_and_server_rows() {
3686 for barrier in [
3687 "preflight",
3688 "purge",
3689 "rebootstrap",
3690 "subscription",
3691 "failed-purge-replay",
3692 "failed-purge-publish",
3693 "failed-rebootstrap",
3694 ] {
3695 let mut client = client();
3696 client.set_meta(LOG_EPOCH_KEY, "epoch");
3697 client
3698 .subscribe(
3699 "tasks".into(),
3700 "tasks".into(),
3701 vec![("project_id".into(), vec!["p1".into()])],
3702 None,
3703 )
3704 .unwrap();
3705 let commit = client
3706 .mutate(vec![Mutation::Upsert {
3707 table: "tasks".into(),
3708 values: Map::from_iter([
3709 ("id".into(), json!("local")),
3710 ("project_id".into(), json!("p1")),
3711 ]),
3712 base_version: None,
3713 }])
3714 .unwrap();
3715 let prepared = client.prepare_sync_round(false).unwrap();
3716 client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
3717 let payload = encode_row_json(
3718 &client.schema.tables[0],
3719 "server",
3720 &Map::from_iter([
3721 ("id".into(), json!("server")),
3722 ("project_id".into(), json!("p1")),
3723 ]),
3724 &client.encryption,
3725 )
3726 .unwrap();
3727 let completed = crate::CompletedSyncRound {
3728 prepared,
3729 exchange: crate::round::ExchangeResult::Reply {
3730 transport_failed: false,
3731 downloads: Default::default(),
3732 response: Ok(Message {
3733 wire_version: WIRE_VERSION,
3734 msg_kind: MsgKind::Response,
3735 frames: vec![
3736 Frame::RespHeader {
3737 required_schema_version: None,
3738 latest_schema_version: None,
3739 log_epoch: Some("epoch".into()),
3740 reset_required: Some(false),
3741 },
3742 Frame::PushResult {
3743 client_commit_id: commit.clone(),
3744 status: PushStatus::Applied,
3745 commit_seq: Some(1),
3746 results: vec![OpResult::Applied { op_index: 0 }],
3747 },
3748 Frame::SubStart {
3749 id: "tasks".into(),
3750 status: SubStatus::Active,
3751 reason_code: String::new(),
3752 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
3753 bootstrap: true,
3754 },
3755 Frame::Commit {
3756 commit_seq: 1,
3757 created_at_ms: 0,
3758 actor_id: "actor".into(),
3759 tables: vec!["tasks".into()],
3760 changes: vec![ssp2::model::Change {
3761 table_index: 0,
3762 row_id: "server".into(),
3763 op: Op::Upsert,
3764 row_version: Some(1),
3765 scopes: vec![("project_id".into(), "p1".into())],
3766 row: Some(payload),
3767 }],
3768 },
3769 Frame::SubEnd {
3770 next_cursor: 1,
3771 bootstrap_state: None,
3772 },
3773 ],
3774 }),
3775 },
3776 };
3777 match barrier {
3778 "preflight" => client.begin_security_preflight(),
3779 "purge" => {
3780 client
3781 .purge_local_data(&LocalDataPurgeInput {
3782 purge_id: "pending-round".into(),
3783 targets: vec![LocalDataPurgeTarget {
3784 table: "tasks".into(),
3785 selectors: BTreeMap::from([(
3786 "project_id".into(),
3787 vec!["p1".into()],
3788 )]),
3789 }],
3790 })
3791 .unwrap();
3792 }
3793 "rebootstrap" => {
3794 client
3795 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3796 rebootstrap_id: "pending-round".into(),
3797 })
3798 .unwrap();
3799 }
3800 "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
3801 let trigger = if barrier == "failed-purge-replay" {
3802 "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
3803 } else {
3804 "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
3805 };
3806 client.conn.execute_batch(trigger).unwrap();
3807 let revision = client.local_revision();
3808 if barrier == "failed-rebootstrap" {
3809 assert!(client
3810 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3811 rebootstrap_id: "failed-round".into(),
3812 })
3813 .is_err());
3814 } else {
3815 assert!(client
3816 .purge_local_data(&LocalDataPurgeInput {
3817 purge_id: "failed-round".into(),
3818 targets: vec![LocalDataPurgeTarget {
3819 table: "tasks".into(),
3820 selectors: BTreeMap::from([(
3821 "project_id".into(),
3822 vec!["p1".into()]
3823 )]),
3824 }],
3825 })
3826 .is_err());
3827 }
3828 assert_eq!(client.local_revision(), revision);
3829 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
3830 client
3831 .conn
3832 .execute_batch("DROP TRIGGER fail_local")
3833 .unwrap();
3834 let applied = client.apply_sync_round(completed);
3835 assert!(
3836 matches!(
3837 applied,
3838 crate::AppliedSyncRound::Complete {
3839 outcome: SyncOutcome::Ok(_),
3840 ..
3841 }
3842 ),
3843 "{barrier}"
3844 );
3845 assert!(client.pending_commit_ids().is_empty());
3846 assert_eq!(
3847 client
3848 .query("SELECT id FROM tasks WHERE id = 'server'", &[])
3849 .unwrap()
3850 .len(),
3851 1
3852 );
3853 continue;
3854 }
3855 "subscription" => {
3856 client.unsubscribe("tasks");
3857 client
3858 .subscribe(
3859 "tasks".into(),
3860 "tasks".into(),
3861 vec![("project_id".into(), vec!["p2".into()])],
3862 None,
3863 )
3864 .unwrap();
3865 }
3866 _ => unreachable!(),
3867 }
3868 let pending_before_reply = client.pending_commit_ids();
3869 let revision = client.local_revision();
3870 let applied = client.apply_sync_round(completed);
3871 assert!(
3872 matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
3873 );
3874 assert_eq!(client.local_revision(), revision);
3875 assert!(client
3876 .conn
3877 .query_row(
3878 "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
3879 [],
3880 |r| r.get::<_, bool>(0)
3881 )
3882 .unwrap());
3883 assert_eq!(client.pending_commit_ids(), pending_before_reply);
3884 }
3885 }
3886
3887 #[test]
3888 fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
3889 struct DeadlineRecording {
3890 budget: std::time::Duration,
3891 seen: Vec<Option<std::time::Instant>>,
3892 }
3893 impl Transport for DeadlineRecording {
3894 fn round_deadline(&self) -> Option<std::time::Duration> {
3895 Some(self.budget)
3896 }
3897 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
3898 self.seen.push(deadline);
3899 }
3900 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
3901 Err(TransportError::new("transport.failed", "injected"))
3902 }
3903 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
3904 self.sync(request)
3905 }
3906 fn download_segment(
3907 &mut self,
3908 _: &SegmentRequest,
3909 _: &mut dyn FnMut(u64),
3910 ) -> Result<Vec<u8>, TransportError> {
3911 Err(TransportError::new("transport.failed", "injected"))
3912 }
3913 fn blob_upload_grant(
3914 &mut self,
3915 _: &str,
3916 _: u64,
3917 _: Option<&str>,
3918 ) -> Result<crate::BlobUploadGrant, TransportError> {
3919 Ok(crate::BlobUploadGrant::Present)
3920 }
3921 fn realtime_connect(&mut self) -> Result<(), TransportError> {
3922 Ok(())
3923 }
3924 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
3925 Ok(())
3926 }
3927 fn realtime_close(&mut self) -> Result<(), TransportError> {
3928 Ok(())
3929 }
3930 }
3931
3932 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
3933 {"name":"id","type":"string","nullable":false},
3934 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
3935 let mut client =
3936 SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
3937 client.set_meta(LOG_EPOCH_KEY, "epoch");
3938 client
3939 .upload_blob(b"pending upload", Some("text/plain".into()), None)
3940 .unwrap();
3941
3942 let mut transport = DeadlineRecording {
3943 budget: std::time::Duration::from_secs(30),
3944 seen: Vec::new(),
3945 };
3946 let prepared = client.prepare_sync_round(false).unwrap();
3947 assert_eq!(prepared.uploads.len(), 1, "one staged upload");
3948 let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
3950 crate::AppliedSyncRound::Continue(prepared) => prepared,
3951 crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
3952 };
3953 let completed = prepared.exchange(&mut transport);
3955 let anchor = completed
3956 .prepared
3957 .round_deadline_at
3958 .expect("the upload round anchors a deadline");
3959 assert!(matches!(
3960 client.apply_sync_round(completed),
3961 crate::AppliedSyncRound::Complete { .. }
3962 ));
3963 assert_eq!(transport.seen.len(), 4, "one scope per exchange");
3964 assert_eq!(
3965 transport.seen[0],
3966 Some(anchor),
3967 "the upload anchors the round"
3968 );
3969 assert_eq!(
3970 transport.seen[1], None,
3971 "the upload exchange clears its scope"
3972 );
3973 assert_eq!(
3974 transport.seen[2],
3975 Some(anchor),
3976 "a continuation reuses the carried anchor instead of refreshing it"
3977 );
3978 assert_eq!(transport.seen[3], None, "the continuation clears its scope");
3979
3980 transport.budget = std::time::Duration::from_secs(60);
3983 let prepared = client.prepare_sync_round(false).unwrap();
3984 let completed = prepared.exchange(&mut transport);
3985 let fresh = completed
3986 .prepared
3987 .round_deadline_at
3988 .expect("the next round anchors a deadline");
3989 client.apply_sync_round(completed);
3990 assert!(
3991 fresh >= anchor + std::time::Duration::from_secs(30),
3992 "the next round anchors a fresh deadline from the new budget"
3993 );
3994 assert_eq!(transport.seen[4], Some(fresh));
3995 assert_eq!(
3996 transport.seen[5], None,
3997 "the new round clears its scope too"
3998 );
3999 }
4000
4001 #[derive(Default)]
4004 struct RoundDeadlineProbe {
4005 budget: Option<std::time::Duration>,
4006 budget_queries: std::cell::Cell<usize>,
4007 scoped: Vec<Option<std::time::Instant>>,
4008 }
4009
4010 impl Transport for RoundDeadlineProbe {
4011 fn round_deadline(&self) -> Option<std::time::Duration> {
4012 self.budget_queries.set(self.budget_queries.get() + 1);
4013 self.budget
4014 }
4015 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4016 self.scoped.push(deadline);
4017 }
4018 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4019 Err(TransportError::new("transport.failed", "injected"))
4020 }
4021 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4022 self.sync(request)
4023 }
4024 fn download_segment(
4025 &mut self,
4026 _: &SegmentRequest,
4027 _: &mut dyn FnMut(u64),
4028 ) -> Result<Vec<u8>, TransportError> {
4029 Err(TransportError::new("transport.failed", "injected"))
4030 }
4031 fn blob_upload_grant(
4032 &mut self,
4033 _: &str,
4034 _: u64,
4035 _: Option<&str>,
4036 ) -> Result<crate::BlobUploadGrant, TransportError> {
4037 Ok(crate::BlobUploadGrant::Present)
4038 }
4039 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4040 Ok(())
4041 }
4042 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4043 Ok(())
4044 }
4045 fn realtime_close(&mut self) -> Result<(), TransportError> {
4046 Ok(())
4047 }
4048 }
4049
4050 #[test]
4051 fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
4052 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4053 {"name":"id","type":"string","nullable":false},
4054 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4055 let mut client =
4056 SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
4057 client.set_meta(LOG_EPOCH_KEY, "epoch");
4058 client
4059 .upload_blob(b"pending upload", Some("text/plain".into()), None)
4060 .unwrap();
4061 client
4062 .conn
4063 .execute_batch("DELETE FROM _syncular_blobs")
4064 .unwrap();
4065 let prepared = client.prepare_sync_round(false).unwrap();
4066 assert!(
4067 prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
4068 "the staged body must fail locally"
4069 );
4070 let mut transport = RoundDeadlineProbe {
4071 budget: Some(std::time::Duration::from_secs(30)),
4072 ..Default::default()
4073 };
4074 let completed = prepared.exchange(&mut transport);
4075 assert!(matches!(
4076 completed.exchange,
4077 crate::round::ExchangeResult::Upload { result: Err(_), .. }
4078 ));
4079 assert_eq!(
4080 transport.budget_queries.get(),
4081 0,
4082 "a round with no network work never asks for a budget"
4083 );
4084 assert_eq!(transport.scoped, vec![None]);
4085 }
4086
4087 #[test]
4088 fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
4089 struct HugeBudget;
4090 impl Transport for HugeBudget {
4091 fn round_deadline(&self) -> Option<std::time::Duration> {
4092 Some(std::time::Duration::MAX)
4093 }
4094 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4095 panic!("no network for an invalid budget")
4096 }
4097 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4098 self.sync(request)
4099 }
4100 fn download_segment(
4101 &mut self,
4102 _: &SegmentRequest,
4103 _: &mut dyn FnMut(u64),
4104 ) -> Result<Vec<u8>, TransportError> {
4105 panic!("no network for an invalid budget")
4106 }
4107 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4108 Ok(())
4109 }
4110 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4111 Ok(())
4112 }
4113 fn realtime_close(&mut self) -> Result<(), TransportError> {
4114 Ok(())
4115 }
4116 }
4117
4118 let mut client = client();
4119 client.set_meta(LOG_EPOCH_KEY, "epoch");
4120 let prepared = client.prepare_sync_round(false).unwrap();
4121 let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
4122 crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
4123 crate::AppliedSyncRound::Continue(_) => {
4124 panic!("an unrepresentable budget must not continue")
4125 }
4126 };
4127 match outcome {
4128 SyncOutcome::Failed {
4129 error_code,
4130 message,
4131 ..
4132 } => {
4133 assert_eq!(error_code, "sync.invalid_request");
4134 assert_eq!(message, "transport round deadline is out of range");
4135 }
4136 _ => panic!("an unrepresentable budget must fail synchronously"),
4137 }
4138 }
4139
4140 #[test]
4141 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
4142 for failure in ["visible", "revision", "commit"] {
4143 let mut client = client();
4144 let id = client
4145 .mutate(vec![Mutation::Upsert {
4146 table: "tasks".into(),
4147 values: Map::from_iter([
4148 ("id".into(), Value::from("t1")),
4149 ("project_id".into(), Value::from("pending")),
4150 ]),
4151 base_version: None,
4152 }])
4153 .unwrap();
4154 client.drain_change_batches();
4155 let revision = client.local_revision();
4156 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
4157 let trigger = match failure {
4158 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
4159 "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"),
4160 _ => "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(),
4161 };
4162 client.conn.execute_batch(&trigger).unwrap();
4163 let change = ssp2::model::Change {
4164 table_index: 0,
4165 row_id: "t1".into(),
4166 op: Op::Upsert,
4167 row_version: Some(2),
4168 scopes: vec![],
4169 row: Some(
4170 encode_row_json(
4171 client.schema.table("tasks").unwrap(),
4172 "t1",
4173 &Map::from_iter([
4174 ("id".into(), Value::from("t1")),
4175 ("project_id".into(), Value::from("remote")),
4176 ]),
4177 &client.encryption,
4178 )
4179 .unwrap(),
4180 ),
4181 };
4182 assert!(client
4183 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
4184 .is_err());
4185 assert!(client.conn.is_autocommit());
4186 assert!(!client.overlay_dirty.get());
4187 assert_eq!(client.local_revision(), revision);
4188 assert!(client.drain_change_batches().is_empty());
4189 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
4190 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
4191 assert!(client
4192 .query("SELECT * FROM _syncular_base_tasks", &[])
4193 .unwrap()
4194 .is_empty());
4195 client
4196 .conn
4197 .execute_batch("DROP TRIGGER fail_pending")
4198 .unwrap();
4199 client
4200 .apply_commit_frame(&["tasks".into()], &[change], None)
4201 .unwrap();
4202 assert_eq!(client.local_revision(), revision + 1);
4203 assert_eq!(client.pending_commit_ids(), vec![id]);
4204 let mut expected = before[0].clone();
4207 expected.insert("_syncular_version".to_owned(), Value::from(2));
4208 assert_eq!(
4209 client.query("SELECT * FROM tasks", &[]).unwrap(),
4210 vec![expected]
4211 );
4212 assert_eq!(
4213 client
4214 .conn
4215 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
4216 .get::<_, String>(0))
4217 .unwrap(),
4218 "remote"
4219 );
4220 }
4221 }
4222
4223 #[test]
4224 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
4225 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
4226 "columns": [
4227 {"name":"id", "type":"string", "nullable":false},
4228 {"name":"project_id", "type":"string", "nullable":false},
4229 {"name":"title", "type":"string", "nullable":false}],
4230 "scopes": [{"pattern":"project:{project_id}"}],
4231 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
4232 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
4233 let mut incremental =
4234 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
4235 let mut reference =
4236 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
4237 for instance in [&mut incremental, &mut reference] {
4238 for index in 0..16 {
4239 instance
4240 .write_base_row(
4241 "tasks",
4242 &vec![
4243 Some(ColumnValue::String(format!("task{index}"))),
4244 Some(ColumnValue::String("p0".to_owned())),
4245 Some(ColumnValue::String(format!("base{index}"))),
4246 ],
4247 1,
4248 )
4249 .unwrap();
4250 }
4251 instance
4252 .rebuild_overlay_if_dirty()
4253 .expect("rebuild overlay");
4254 instance.overlay_rebuild_count.set(0);
4255 }
4256 for index in 0..100 {
4257 let mutations = (0..1 + index % 4)
4258 .map(|offset| {
4259 let id = format!("task{}", (index + offset) % 24);
4260 if (index + offset) % 7 == 0 {
4261 Mutation::Delete {
4262 table: "tasks".to_owned(),
4263 row_id: id,
4264 base_version: None,
4265 }
4266 } else {
4267 Mutation::Upsert {
4268 table: "tasks".to_owned(),
4269 values: Map::from_iter([
4270 ("id".to_owned(), Value::from(id)),
4271 (
4272 "project_id".to_owned(),
4273 Value::from(format!("p{}", index % 2)),
4274 ),
4275 (
4276 "title".to_owned(),
4277 Value::from(format!("needle{}", (index + offset) % 24)),
4278 ),
4279 ]),
4280 base_version: None,
4281 }
4282 }
4283 })
4284 .collect::<Vec<_>>();
4285 reference.overlay_dirty.set(true);
4286 incremental.mutate(mutations.clone()).unwrap();
4287 reference.mutate(mutations).unwrap();
4288 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
4289 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
4290 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
4291 }
4292 assert_eq!(incremental.local_revision(), reference.local_revision());
4293 assert_eq!(
4294 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4295 serde_json::to_value(reference.drain_change_batches()).unwrap()
4296 );
4297 }
4298 assert_eq!(incremental.outbox.len(), 100);
4299 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4300 assert_eq!(reference.overlay_rebuild_count.get(), 100);
4301 }
4302
4303 #[test]
4304 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
4305 for fail_at in ["outbox", "revision"] {
4306 let mut client = client();
4307 let values = Map::from_iter([
4308 ("id".to_owned(), Value::from("t1")),
4309 ("project_id".to_owned(), Value::from("p1")),
4310 ]);
4311 let mutation = Mutation::Upsert {
4312 table: "tasks".to_owned(),
4313 values,
4314 base_version: None,
4315 };
4316 let trigger = if fail_at == "outbox" {
4317 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
4318 } else {
4319 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
4320 };
4321 client.conn.execute_batch(&trigger).unwrap();
4322 let revision = client.local_revision();
4323 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
4324 assert_eq!(client.local_revision(), revision);
4325 assert!(client.outbox.is_empty());
4326 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
4327 assert_eq!(
4328 client
4329 .conn
4330 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
4331 .get::<_, i64>(0))
4332 .unwrap(),
4333 0
4334 );
4335 assert!(client.drain_change_batches().is_empty());
4336 assert!(!client.overlay_dirty.get());
4337 client
4338 .conn
4339 .execute_batch("DROP TRIGGER fail_append")
4340 .unwrap();
4341 client.mutate(vec![mutation]).unwrap();
4342 assert_eq!(client.outbox.len(), 1);
4343 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
4344 }
4345 }
4346
4347 #[test]
4348 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
4349 let mut client = client();
4350 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
4351 let values = Map::from_iter([
4352 ("id".to_owned(), Value::from("t1")),
4353 ("project_id".to_owned(), Value::from("p1")),
4354 ]);
4355 let payload = encode_row_json(
4356 client.schema.table("tasks").unwrap(),
4357 "t1",
4358 &values,
4359 &client.encryption,
4360 )
4361 .unwrap();
4362 let first = ssp2::model::Change {
4363 table_index: 0,
4364 row_id: "t1".to_owned(),
4365 op: Op::Upsert,
4366 row_version: Some(1),
4367 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
4368 row: Some(payload),
4369 };
4370 let mut invalid = first.clone();
4371 invalid.table_index = 1;
4372 let revision = client.local_revision();
4373 client.begin_observation("failed_mirror").unwrap();
4374 assert!(client
4375 .apply_commit_changes(&tables, &[first, invalid])
4376 .is_err());
4377 client.rollback_observation("failed_mirror");
4378 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4379 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
4380 assert_eq!(
4381 client
4382 .conn
4383 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
4384 .get::<_, i64>(
4385 0
4386 ))
4387 .unwrap(),
4388 0
4389 );
4390 assert_eq!(client.local_revision(), revision);
4391 assert!(client.drain_change_batches().is_empty());
4392 }
4393
4394 #[test]
4395 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
4396 for failure in ["journal", "revision", "commit", "later-frame"] {
4397 let mut client = client();
4398 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4399 for id in ["first", "second"] {
4400 client
4401 .mutate(vec![Mutation::Upsert {
4402 table: "tasks".to_owned(),
4403 values: Map::from_iter([
4404 ("id".to_owned(), Value::from(id)),
4405 ("project_id".to_owned(), Value::from("p1")),
4406 ]),
4407 base_version: None,
4408 }])
4409 .unwrap();
4410 }
4411 let (_, meta) = client.build_request(false).unwrap();
4412 client
4414 .mutate(vec![Mutation::Upsert {
4415 table: "tasks".to_owned(),
4416 values: Map::from_iter([
4417 ("id".to_owned(), Value::from("later")),
4418 ("project_id".to_owned(), Value::from("p1")),
4419 ]),
4420 base_version: None,
4421 }])
4422 .unwrap();
4423 let ids = client.pending_commit_ids();
4424 let rows = client
4425 .query("SELECT * FROM tasks ORDER BY id", &[])
4426 .unwrap();
4427 let revision = client.local_revision();
4428 client.drain_change_batches();
4429 if failure != "later-frame" {
4430 client.conn.execute_batch(match failure {
4431 "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",
4432 "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",
4433 _ => "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",
4434 }).unwrap();
4435 }
4436 let mut response = Message {
4437 wire_version: WIRE_VERSION,
4438 msg_kind: MsgKind::Response,
4439 frames: vec![Frame::RespHeader {
4440 required_schema_version: None,
4441 latest_schema_version: None,
4442 log_epoch: Some("epoch-1".to_owned()),
4443 reset_required: Some(false),
4444 }],
4445 };
4446 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
4447 response.frames.push(Frame::PushResult {
4448 client_commit_id: id.to_owned(),
4449 status: PushStatus::Applied,
4450 commit_seq: Some(1),
4451 results: vec![OpResult::Applied { op_index: 0 }],
4452 });
4453 }
4454 if failure == "later-frame" {
4455 response.frames.push(Frame::Error {
4456 code: "sync.invalid_request".to_owned(),
4457 message: "later error".to_owned(),
4458 category: "protocol".to_owned(),
4459 retryable: false,
4460 recommended_action: "retry".to_owned(),
4461 details: None,
4462 });
4463 }
4464 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
4465 let outcome = client.process_response(&mut transport, response.clone(), &meta);
4466 assert!(
4467 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
4468 );
4469 if failure == "later-frame" {
4470 assert_eq!(client.pending_commit_ids(), ids[2..]);
4471 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
4472 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
4473 let batches = client.drain_change_batches();
4474 assert_eq!(batches.len(), 1);
4475 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
4476 assert_eq!(client.local_revision(), revision + 1);
4477 response.frames.pop();
4478 } else {
4479 assert_eq!(client.pending_commit_ids(), ids);
4480 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
4481 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
4482 assert_eq!(client.local_revision(), revision);
4483 assert!(client.drain_change_batches().is_empty());
4484 assert_eq!(
4485 client
4486 .query("SELECT * FROM tasks ORDER BY id", &[])
4487 .unwrap(),
4488 rows
4489 );
4490 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
4491 }
4492 for frame in &mut response.frames {
4493 if let Frame::PushResult { status, .. } = frame {
4494 *status = PushStatus::Cached;
4495 }
4496 }
4497 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
4498 else {
4499 panic!("retry failed");
4500 };
4501 assert_eq!(
4502 report.applied,
4503 if failure == "later-frame" {
4504 vec![]
4505 } else {
4506 ids[..2].to_vec()
4507 }
4508 );
4509 assert_eq!(client.pending_commit_ids(), ids[2..]);
4510 let batches = client.drain_change_batches();
4511 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
4512 if let Some(batch) = batches.first() {
4513 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
4514 }
4515 assert_eq!(client.local_revision(), revision + 1);
4516 }
4517 }
4518
4519 #[test]
4520 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
4521 for failure in ["revision", "journal", "commit"] {
4522 for status in ["applied", "cached", "rejected", "conflict"] {
4523 let mut client = client();
4524 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4525 for id in ["first", "later"] {
4526 client
4527 .mutate(vec![Mutation::Upsert {
4528 table: "tasks".to_owned(),
4529 values: Map::from_iter([
4530 ("id".to_owned(), Value::from(id)),
4531 ("project_id".to_owned(), Value::from("p1")),
4532 ]),
4533 base_version: None,
4534 }])
4535 .unwrap();
4536 }
4537 let (_, meta) = client.build_request(false).unwrap();
4538 let ids = client.pending_commit_ids();
4539 let rows = client
4540 .query("SELECT * FROM tasks ORDER BY id", &[])
4541 .unwrap();
4542 let revision = client.local_revision();
4543 client.drain_change_batches();
4544 let (push_status, results) = match status {
4545 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
4546 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
4547 "conflict" => (
4548 PushStatus::Rejected,
4549 vec![OpResult::Conflict {
4550 op_index: 0,
4551 code: "sync.version_conflict".to_owned(),
4552 message: "conflict".to_owned(),
4553 server_version: 1,
4554 server_row: encode_row_json(
4555 client.schema.table("tasks").unwrap(),
4556 "first",
4557 &Map::from_iter([
4558 ("id".to_owned(), Value::from("first")),
4559 ("project_id".to_owned(), Value::from("p1")),
4560 ]),
4561 &client.encryption,
4562 )
4563 .unwrap(),
4564 conflict_columns: Vec::new(),
4565 }],
4566 ),
4567 _ => (
4568 PushStatus::Rejected,
4569 vec![OpResult::Error {
4570 op_index: 0,
4571 code: "sync.validation_failed".to_owned(),
4572 message: "rejected".to_owned(),
4573 retryable: false,
4574 }],
4575 ),
4576 };
4577 let response = Message {
4578 wire_version: WIRE_VERSION,
4579 msg_kind: MsgKind::Response,
4580 frames: vec![
4581 Frame::RespHeader {
4582 required_schema_version: None,
4583 latest_schema_version: None,
4584 log_epoch: Some("epoch-1".to_owned()),
4585 reset_required: Some(false),
4586 },
4587 Frame::PushResult {
4588 client_commit_id: ids[0].clone(),
4589 status: push_status,
4590 commit_seq: Some(1),
4591 results,
4592 },
4593 ],
4594 };
4595 client.conn.execute_batch(match failure {
4596 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
4597 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
4598 _ => "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",
4599 }).unwrap();
4600 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
4601 let outcome = client.process_response(&mut transport, response.clone(), &meta);
4602 assert_eq!(
4603 client.pending_commit_ids(),
4604 ids,
4605 "{failure}/{status}: pending identity"
4606 );
4607 assert_eq!(
4608 client.local_revision(),
4609 revision,
4610 "{failure}/{status}: revision"
4611 );
4612 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
4613 assert!(client.conflicts().is_empty());
4614 assert!(client.rejections().is_empty());
4615 assert!(client.drain_change_batches().is_empty());
4616 assert_eq!(
4617 client
4618 .query("SELECT * FROM tasks ORDER BY id", &[])
4619 .unwrap(),
4620 rows
4621 );
4622 assert!(
4623 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
4624 "{failure}/{status}: failed round"
4625 );
4626 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
4627 assert!(matches!(
4628 client.process_response(&mut transport, response, &meta),
4629 SyncOutcome::Ok(_)
4630 ));
4631 assert_eq!(client.pending_commit_ids(), ids[1..]);
4632 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
4633 assert_eq!(client.drain_change_batches().len(), 1);
4634 }
4635 }
4636 }
4637
4638 #[test]
4639 fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
4640 let mut client = client();
4641 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4642 for index in 0..4 {
4643 client
4644 .mutate(vec![Mutation::Upsert {
4645 table: "tasks".to_owned(),
4646 values: Map::from_iter([
4647 ("id".to_owned(), Value::from(format!("task-{index}"))),
4648 ("project_id".to_owned(), Value::from("project-1")),
4649 ]),
4650 base_version: None,
4651 }])
4652 .expect("queue commit");
4653 }
4654
4655 let (_, request_meta) = client.build_request(false).unwrap();
4656 let ids = &request_meta.pushed_ids;
4657 assert_eq!(ids.len(), 4);
4658 let response = Message {
4659 wire_version: WIRE_VERSION,
4660 msg_kind: MsgKind::Response,
4661 frames: vec![
4662 Frame::RespHeader {
4663 required_schema_version: None,
4664 latest_schema_version: None,
4665 log_epoch: Some("epoch-1".to_owned()),
4666 reset_required: Some(false),
4667 },
4668 Frame::PushResult {
4669 client_commit_id: ids[0].clone(),
4670 status: PushStatus::Applied,
4671 commit_seq: Some(1),
4672 results: vec![OpResult::Applied { op_index: 0 }],
4673 },
4674 Frame::PushResult {
4675 client_commit_id: ids[1].clone(),
4676 status: PushStatus::Cached,
4677 commit_seq: Some(2),
4678 results: vec![OpResult::Applied { op_index: 0 }],
4679 },
4680 Frame::PushResult {
4681 client_commit_id: ids[2].clone(),
4682 status: PushStatus::Rejected,
4683 commit_seq: None,
4684 results: vec![OpResult::Error {
4685 op_index: 0,
4686 code: "sync.validation_failed".to_owned(),
4687 message: "rejected".to_owned(),
4688 retryable: false,
4689 }],
4690 },
4691 Frame::PushResult {
4692 client_commit_id: ids[3].clone(),
4693 status: PushStatus::Rejected,
4694 commit_seq: None,
4695 results: vec![OpResult::Error {
4696 op_index: 0,
4697 code: "sync.idempotency_cache_miss".to_owned(),
4698 message: "retry".to_owned(),
4699 retryable: true,
4700 }],
4701 },
4702 ],
4703 };
4704 let mut transport =
4705 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
4706
4707 client.overlay_rebuild_count.set(0);
4708 client.outcome_prune_count.set(0);
4709 let outcome = client.process_response(&mut transport, response, &request_meta);
4710 let SyncOutcome::Ok(report) = outcome else {
4711 panic!("mixed push-result response failed");
4712 };
4713
4714 assert_eq!(report.applied, ids[..2]);
4715 assert_eq!(report.rejected, ids[2..3]);
4716 assert_eq!(report.retryable, ids[3..4]);
4717 assert_eq!(client.pending_commit_ids(), ids[3..4]);
4718 assert_eq!(
4719 client
4720 .query("SELECT id FROM tasks ORDER BY id", &[])
4721 .expect("query visible overlay"),
4722 ["task-0", "task-1", "task-3"]
4723 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
4724 .to_vec()
4725 );
4726 assert_eq!(client.overlay_rebuild_count.get(), 2);
4728 assert_eq!(client.outcome_prune_count.get(), 1);
4729 }
4730
4731 fn replay_schema(fts: bool) -> Value {
4732 json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
4733 {"name":"id","type":"string","nullable":false},
4734 {"name":"project_id","type":"string","nullable":false},
4735 {"name":"title","type":"string","nullable":false},
4736 {"name":"done","type":"boolean","nullable":false}
4737 ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
4738 }
4739
4740 #[test]
4741 fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
4742 for schema_reset in [false, true] {
4743 let mut schema = replay_schema(false);
4744 let mut client =
4745 SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
4746 let id = client
4747 .mutate(vec![Mutation::Upsert {
4748 table: "tasks".into(),
4749 values: Map::from_iter([
4750 ("id".into(), json!("queued")),
4751 ("project_id".into(), json!("p1")),
4752 ("title".into(), json!("old")),
4753 ("done".into(), json!(false)),
4754 ]),
4755 base_version: None,
4756 }])
4757 .unwrap();
4758 client.set_meta(LOG_EPOCH_KEY, "old");
4759 let revision = client.local_revision();
4760 let needed = client.sync_needed;
4761 client.drain_change_batches();
4762 client.drain_sync_intents();
4763 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();
4764 if schema_reset {
4765 schema["version"] = json!(2);
4766 schema["tables"][0]["columns"]
4767 .as_array_mut()
4768 .unwrap()
4769 .retain(|column| column["name"] != "title");
4770 client.schema = parse_schema_json(&schema).unwrap();
4771 assert!(client.run_schema_reset().is_err());
4772 } else {
4773 assert!(client.run_log_epoch_reset("new").is_err());
4774 }
4775 assert!(!client.upgrading);
4776 assert_eq!(client.sync_needed, needed);
4777 assert_eq!(client.local_revision(), revision);
4778 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
4779 assert!(client.rejections.is_empty());
4780 assert!(client.commit_outcome(&id).unwrap().is_none());
4781 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
4782 assert_eq!(
4783 client
4784 .query("SELECT title FROM tasks WHERE id='queued'", &[])
4785 .unwrap()[0]["title"],
4786 "old"
4787 );
4788 assert_eq!(
4789 client
4790 .conn
4791 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
4792 .get::<_, i64>(0))
4793 .unwrap(),
4794 1
4795 );
4796 assert!(client.drain_change_batches().is_empty());
4797 assert!(client.drain_sync_intents().is_empty());
4798 assert!(client.conn.is_autocommit());
4799 }
4800 }
4801
4802 #[test]
4803 fn cached_blob_cleanup_propagates_delete_failures() {
4804 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4805 {"name":"id","type":"string","nullable":false},
4806 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4807 let mut client =
4808 SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
4809 client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
4810 client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
4811 client
4812 .subscribe("attachments".into(), "attachments".into(), vec![], None)
4813 .unwrap();
4814 client.subs[0].effective = Some(vec![]);
4815 client.persist_sub(&client.subs[0]).unwrap();
4816 let revision = client.local_revision();
4817 let (_, meta) = client.build_request(false).unwrap();
4818 let mut transport = CountingRealtimeTransport::default();
4819 let mut report = SyncReport::default();
4820 assert!(client
4821 .process_section(
4822 &mut transport,
4823 "attachments",
4824 SubStatus::Revoked,
4825 "sync.scope_revoked",
4826 vec![],
4827 vec![],
4828 Some((0, None)),
4829 &meta,
4830 &mut report
4831 )
4832 .is_err());
4833 assert_eq!(client.subs[0].state, SubState::Active);
4834 assert_eq!(client.local_revision(), revision);
4835 assert!(report.revoked.is_empty());
4836 assert!(client.conn.is_autocommit());
4837 client
4838 .conn
4839 .execute_batch("DROP TRIGGER refuse_blob_cleanup")
4840 .unwrap();
4841 assert!(client
4842 .process_section(
4843 &mut transport,
4844 "attachments",
4845 SubStatus::Revoked,
4846 "sync.scope_revoked",
4847 vec![],
4848 vec![],
4849 Some((0, None)),
4850 &meta,
4851 &mut report
4852 )
4853 .is_ok());
4854 assert_eq!(client.subs[0].state, SubState::Revoked);
4855 assert_eq!(
4856 client
4857 .conn
4858 .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
4859 .get::<_, i64>(0))
4860 .unwrap(),
4861 0
4862 );
4863 }
4864
4865 #[test]
4866 fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
4867 let path =
4868 std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
4869 let schema = replay_schema(false);
4870 let mut client = SyncClient::open_path(
4871 "replay".into(),
4872 &schema,
4873 ClientLimits::default(),
4874 path.to_str().unwrap(),
4875 )
4876 .unwrap();
4877 let ids = ["early", "bad"].map(|id| {
4878 client
4879 .mutate(vec![Mutation::Upsert {
4880 table: "tasks".into(),
4881 values: Map::from_iter([
4882 ("id".into(), json!(id)),
4883 ("project_id".into(), json!("p1")),
4884 ("title".into(), json!(id)),
4885 ("done".into(), json!(false)),
4886 ]),
4887 base_version: None,
4888 }])
4889 .unwrap()
4890 });
4891 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();
4892 let revision = client.local_revision();
4893 drop(client);
4894 let error = SyncClient::open_path(
4895 "replay".into(),
4896 &schema,
4897 ClientLimits::default(),
4898 path.to_str().unwrap(),
4899 )
4900 .err()
4901 .expect("reopen must fail");
4902 assert!(error.contains("replay failed"));
4903 let conn = Connection::open(&path).unwrap();
4904 assert_eq!(
4905 conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
4906 .unwrap(),
4907 0
4908 );
4909 assert_eq!(
4910 conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
4911 .get::<_, i64>(0))
4912 .unwrap(),
4913 2
4914 );
4915 assert_eq!(
4916 meta_get(&conn, LOCAL_REVISION_KEY),
4917 Some(revision.to_string())
4918 );
4919 conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
4920 drop(conn);
4921 let reopened = SyncClient::open_path(
4922 "replay".into(),
4923 &schema,
4924 ClientLimits::default(),
4925 path.to_str().unwrap(),
4926 )
4927 .unwrap();
4928 assert_eq!(reopened.pending_commit_ids(), ids);
4929 assert_eq!(
4930 reopened
4931 .query("SELECT id FROM tasks ORDER BY id", &[])
4932 .unwrap()
4933 .len(),
4934 2
4935 );
4936 drop(reopened);
4937 std::fs::remove_file(path).unwrap();
4938 }
4939
4940 #[test]
4941 fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
4942 let schema = replay_schema(false);
4943 let mut client =
4944 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
4945 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4946 client
4947 .write_base_row(
4948 "tasks",
4949 &vec![
4950 Some(ColumnValue::String("queued".into())),
4951 Some(ColumnValue::String("p1".into())),
4952 Some(ColumnValue::String("server".into())),
4953 Some(ColumnValue::Boolean(false)),
4954 ],
4955 1,
4956 )
4957 .unwrap();
4958 client.rebuild_overlay().unwrap();
4959 let id = client
4960 .patch(
4961 "tasks",
4962 "queued",
4963 Map::from_iter([("title".into(), json!("local"))]),
4964 Some(1),
4965 )
4966 .unwrap();
4967 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
4968 let base = client
4969 .query("SELECT * FROM _syncular_base_tasks", &[])
4970 .unwrap();
4971 let revision = client.local_revision();
4972 client.drain_change_batches();
4973 client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
4974 let remote = vec![
4975 Some(ColumnValue::String("queued".into())),
4976 Some(ColumnValue::String("p1".into())),
4977 Some(ColumnValue::String("remote".into())),
4978 Some(ColumnValue::Boolean(false)),
4979 ];
4980 let bytes = crate::values::encode_row_json(
4981 client.schema.table("tasks").unwrap(),
4982 "queued",
4983 &Map::from_iter(
4984 client
4985 .schema
4986 .table("tasks")
4987 .unwrap()
4988 .columns
4989 .iter()
4990 .zip(&remote)
4991 .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
4992 ),
4993 &client.encryption,
4994 )
4995 .unwrap();
4996 let change = ssp2::model::Change {
4997 table_index: 0,
4998 op: Op::Upsert,
4999 row_id: "queued".into(),
5000 row_version: Some(2),
5001 scopes: vec![("project_id".into(), "p1".into())],
5002 row: Some(bytes),
5003 };
5004 assert!(client
5005 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
5006 .is_err());
5007 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5008 assert_eq!(
5009 client
5010 .query("SELECT * FROM _syncular_base_tasks", &[])
5011 .unwrap(),
5012 base
5013 );
5014 assert_eq!(client.local_revision(), revision);
5015 assert!(client.drain_change_batches().is_empty());
5016 let response = Message {
5017 wire_version: 2,
5018 msg_kind: MsgKind::Response,
5019 frames: vec![
5020 Frame::RespHeader {
5021 required_schema_version: None,
5022 latest_schema_version: None,
5023 log_epoch: Some("epoch-1".into()),
5024 reset_required: Some(false),
5025 },
5026 Frame::PushResult {
5027 client_commit_id: id.clone(),
5028 status: PushStatus::Applied,
5029 commit_seq: Some(2),
5030 results: vec![OpResult::Applied { op_index: 0 }],
5031 },
5032 ],
5033 };
5034 let (_, meta) = client.build_request(false).unwrap();
5035 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5036 assert!(
5037 matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
5038 );
5039 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5040 assert!(client.commit_outcome(&id).unwrap().is_none());
5041 assert_eq!(
5042 client
5043 .conn
5044 .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
5045 .get::<_, i64>(
5046 0
5047 ))
5048 .unwrap(),
5049 0
5050 );
5051 assert_eq!(
5052 client
5053 .conn
5054 .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
5055 row.get::<_, i64>(0)
5056 })
5057 .unwrap(),
5058 0
5059 );
5060 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5061 assert_eq!(client.local_revision(), revision);
5062 assert!(client.drain_change_batches().is_empty());
5063 client
5064 .conn
5065 .execute_batch("DROP TRIGGER fail_replay")
5066 .unwrap();
5067 assert!(matches!(
5068 client.process_response(&mut transport, response, &meta),
5069 SyncOutcome::Ok(_)
5070 ));
5071 client
5072 .apply_commit_frame(&["tasks".into()], &[change], Some(2))
5073 .unwrap();
5074 assert!(client.pending_commit_ids().is_empty());
5075 assert_eq!(
5076 client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
5077 json!("remote")
5078 );
5079 }
5080
5081 #[test]
5082 fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
5083 let client = SyncClient::new(
5084 "replay".into(),
5085 &replay_schema(false),
5086 ClientLimits::default(),
5087 )
5088 .unwrap();
5089 let table = client.schema.table("tasks").unwrap();
5090 let patch = OutboxOp {
5091 table: "tasks".into(),
5092 row_id: "absent".into(),
5093 upsert: true,
5094 base_version: Some(1),
5095 values: Some(Map::from_iter([
5096 ("id".into(), json!("absent")),
5097 ("title".into(), json!("patch")),
5098 ])),
5099 };
5100 assert!(client.visible_row(table, "absent").unwrap().is_none());
5101 client
5102 .apply_outbox_ops([&patch])
5103 .expect("sparse replay over truly absent row");
5104 client
5105 .conn
5106 .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
5107 .unwrap();
5108 assert!(client
5109 .visible_row(table, "bad")
5110 .unwrap_err()
5111 .starts_with("sync.local_corrupt:"));
5112 let mut invalid = patch.clone();
5113 invalid.values = Some(Map::from_iter([
5114 ("id".into(), json!("bad-value")),
5115 ("project_id".into(), json!("p1")),
5116 ("title".into(), json!("title")),
5117 ("done".into(), json!({"bad":true})),
5118 ]));
5119 assert!(client
5120 .apply_outbox_ops([&invalid])
5121 .unwrap_err()
5122 .starts_with("sync.local_corrupt:"));
5123 client.conn.execute_batch("DROP TABLE tasks").unwrap();
5124 assert!(
5125 client.apply_outbox_ops([&patch]).is_err(),
5126 "SQL failure must not count as absence"
5127 );
5128 }
5129
5130 #[test]
5131 fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
5132 let mut schema = replay_schema(false);
5133 schema["tables"][0]["indexes"] =
5134 json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
5135 let mut client =
5136 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5137 let id = client
5138 .mutate(vec![Mutation::Upsert {
5139 table: "tasks".into(),
5140 values: Map::from_iter([
5141 ("id".into(), json!("queued")),
5142 ("project_id".into(), json!("p1")),
5143 ("title".into(), json!("occupied")),
5144 ("done".into(), json!(false)),
5145 ]),
5146 base_version: None,
5147 }])
5148 .unwrap();
5149 client
5150 .write_base_row(
5151 "tasks",
5152 &vec![
5153 Some(ColumnValue::String("server".into())),
5154 Some(ColumnValue::String("p1".into())),
5155 Some(ColumnValue::String("occupied".into())),
5156 Some(ColumnValue::Boolean(false)),
5157 ],
5158 1,
5159 )
5160 .unwrap();
5161 client.rebuild_overlay_if_dirty().unwrap();
5162 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5163 assert!(client
5164 .visible_row(client.schema.table("tasks").unwrap(), "queued")
5165 .unwrap()
5166 .is_none());
5167 assert!(client.rejections().is_empty());
5168 client.delete_base_row("tasks", "server").unwrap();
5169 client.rebuild_overlay_if_dirty().unwrap();
5170 assert!(client
5171 .visible_row(client.schema.table("tasks").unwrap(), "queued")
5172 .unwrap()
5173 .is_some());
5174 client
5175 .write_base_row(
5176 "tasks",
5177 &vec![
5178 Some(ColumnValue::String("base".into())),
5179 Some(ColumnValue::String("p1".into())),
5180 Some(ColumnValue::String("base-title".into())),
5181 Some(ColumnValue::Boolean(false)),
5182 ],
5183 1,
5184 )
5185 .unwrap();
5186 client.conn.execute_batch("DROP TABLE tasks").unwrap();
5187 client
5188 .retain_failed_commit(
5189 "retained",
5190 &[OutboxOp {
5191 table: "tasks".into(),
5192 row_id: "base".into(),
5193 upsert: true,
5194 base_version: Some(1),
5195 values: Some(Map::from_iter([
5196 ("id".into(), json!("base")),
5197 ("title".into(), json!("patch")),
5198 ])),
5199 }],
5200 )
5201 .expect("an existing base must not read the unavailable visible table");
5202 }
5203
5204 #[test]
5205 fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
5206 for failure in ["fts", "release"] {
5207 let mut client = SyncClient::new(
5208 "replay".into(),
5209 &replay_schema(true),
5210 ClientLimits::default(),
5211 )
5212 .unwrap();
5213 client
5214 .mutate(vec![Mutation::Upsert {
5215 table: "tasks".into(),
5216 values: Map::from_iter([
5217 ("id".into(), json!("queued")),
5218 ("project_id".into(), json!("p1")),
5219 ("title".into(), json!("needle")),
5220 ("done".into(), json!(false)),
5221 ]),
5222 base_version: None,
5223 }])
5224 .unwrap();
5225 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5226 let fts_before = client
5227 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
5228 .unwrap();
5229 let triggers_before: Vec<String> = client
5230 .conn
5231 .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
5232 .unwrap()
5233 .query_map([], |row| row.get(0))
5234 .unwrap()
5235 .collect::<Result<_, _>>()
5236 .unwrap();
5237 if failure == "fts" {
5238 client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
5239 } else {
5240 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();
5241 }
5242 client.overlay_dirty.set(true);
5243 assert!(client.rebuild_overlay().is_err());
5244 assert!(client.overlay_dirty.get());
5245 assert!(client.conn.is_autocommit());
5246 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5247 assert_eq!(
5248 client
5249 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
5250 .unwrap(),
5251 fts_before
5252 );
5253 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();
5254 assert_eq!(original_triggers, triggers_before);
5255 assert_eq!(client.pending_commit_ids().len(), 1);
5256 client
5257 .conn
5258 .execute_batch(if failure == "fts" {
5259 "DROP TRIGGER fail_fts"
5260 } else {
5261 "DROP TRIGGER fail_release"
5262 })
5263 .unwrap();
5264 client
5265 .rebuild_overlay()
5266 .expect("retry after removing the fault");
5267 }
5268 }
5269
5270 #[test]
5271 fn secondary_unique_collision_preserves_existing_synced_row() {
5272 let client = SyncClient::new(
5273 "unique-upsert-test".to_owned(),
5274 &json!({
5275 "version": 1,
5276 "tables": [{
5277 "name": "tasks",
5278 "primaryKey": "id",
5279 "columns": [
5280 { "name": "id", "type": "string", "nullable": false },
5281 { "name": "project_id", "type": "string", "nullable": false },
5282 { "name": "title", "type": "string", "nullable": false }
5283 ],
5284 "scopes": [{ "pattern": "project:{project_id}" }],
5285 "indexes": [{
5286 "name": "tasks_by_project_title",
5287 "columns": ["project_id", "title"],
5288 "unique": true
5289 }]
5290 }]
5291 }),
5292 ClientLimits::default(),
5293 )
5294 .expect("test client");
5295 let table = client.schema.table("tasks").expect("tasks table");
5296 let sql = client.insert_row_sql(&base_table("tasks"), table);
5297
5298 client
5299 .conn
5300 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
5301 .expect("insert first row");
5302 client
5303 .conn
5304 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
5305 .expect("update same primary key");
5306 client
5307 .conn
5308 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
5309 .expect("insert second row");
5310 assert!(client
5311 .conn
5312 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
5313 .is_err());
5314
5315 let rows = client
5316 .conn
5317 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
5318 .expect("prepare rows")
5319 .query_map([], |row| {
5320 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5321 })
5322 .expect("query rows")
5323 .collect::<Result<Vec<_>, _>>()
5324 .expect("collect rows");
5325 assert_eq!(
5326 rows,
5327 vec![
5328 ("t1".to_owned(), "updated".to_owned()),
5329 ("t2".to_owned(), "original".to_owned())
5330 ]
5331 );
5332 }
5333
5334 #[test]
5335 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
5336 let path = std::env::temp_dir().join(format!(
5337 "syncular-startup-intent-{}.db",
5338 uuid::Uuid::new_v4()
5339 ));
5340 let schema = json!({
5341 "version": 1,
5342 "tables": [{
5343 "name": "tasks",
5344 "primaryKey": "id",
5345 "columns": [
5346 { "name": "id", "type": "string", "nullable": false },
5347 { "name": "project_id", "type": "string", "nullable": false }
5348 ],
5349 "scopes": [{ "pattern": "project:{project_id}" }]
5350 }]
5351 });
5352
5353 {
5354 let mut first = SyncClient::open_path_with_identity(
5355 None,
5356 &schema,
5357 ClientLimits::default(),
5358 path.to_str().expect("UTF-8 temp path"),
5359 )
5360 .expect("first open");
5361 first
5362 .set_window(
5363 &WindowBase {
5364 table: "tasks".to_owned(),
5365 variable: "project_id".to_owned(),
5366 fixed_scopes: Vec::new(),
5367 params: None,
5368 },
5369 &["persisted".to_owned()],
5370 )
5371 .expect("persist window");
5372 }
5373
5374 let mut reopened = SyncClient::open_path_with_identity(
5375 None,
5376 &schema,
5377 ClientLimits::default(),
5378 path.to_str().expect("UTF-8 temp path"),
5379 )
5380 .expect("reopen");
5381 assert!(reopened.sync_needed());
5382 assert!(matches!(
5383 reopened.drain_sync_intents().as_slice(),
5384 [SyncIntent::Interactive]
5385 ));
5386 drop(reopened);
5387 std::fs::remove_file(path).expect("remove temp database");
5388 }
5389
5390 #[test]
5391 fn reopening_preserves_immutable_subscription_identity_and_progress() {
5392 let path = std::env::temp_dir().join(format!(
5393 "syncular-subscription-identity-{}.db",
5394 uuid::Uuid::new_v4()
5395 ));
5396 let schema = json!({
5397 "version": 1,
5398 "tables": [
5399 {
5400 "name": "tasks",
5401 "primaryKey": "id",
5402 "columns": [
5403 { "name": "id", "type": "string", "nullable": false },
5404 { "name": "project_id", "type": "string", "nullable": false }
5405 ],
5406 "scopes": [{ "pattern": "project:{project_id}" }]
5407 },
5408 {
5409 "name": "docs",
5410 "primaryKey": "id",
5411 "columns": [
5412 { "name": "id", "type": "string", "nullable": false },
5413 { "name": "org_id", "type": "string", "nullable": false },
5414 { "name": "project_id", "type": "string", "nullable": false }
5415 ],
5416 "scopes": [
5417 { "pattern": "org:{org_id}" },
5418 { "pattern": "project:{projectId}", "column": "project_id" }
5419 ]
5420 }
5421 ]
5422 });
5423
5424 {
5425 let mut first = SyncClient::open_path_with_identity(
5426 None,
5427 &schema,
5428 ClientLimits::default(),
5429 path.to_str().expect("UTF-8 temp path"),
5430 )
5431 .expect("first open");
5432 first
5433 .subscribe(
5434 "stable-subscription".to_owned(),
5435 "tasks".to_owned(),
5436 vec![(
5437 "project_id".to_owned(),
5438 vec!["p2".to_owned(), "p1".to_owned()],
5439 )],
5440 Some(r#"{"view":"v1"}"#.to_owned()),
5441 )
5442 .expect("persist subscription");
5443 let persisted = {
5444 let subscription = first
5445 .subs
5446 .iter_mut()
5447 .find(|subscription| subscription.id == "stable-subscription")
5448 .expect("subscription");
5449 subscription.cursor = 41;
5450 subscription.bootstrap_state = Some("resume-token".to_owned());
5451 subscription.effective = Some(vec![(
5452 "project_id".to_owned(),
5453 vec!["p1".to_owned(), "p2".to_owned()],
5454 )]);
5455 subscription.synced_once = true;
5456 subscription.clone()
5457 };
5458 first.persist_sub(&persisted).unwrap();
5459 }
5460
5461 let mut reopened = SyncClient::open_path_with_identity(
5462 None,
5463 &schema,
5464 ClientLimits::default(),
5465 path.to_str().expect("UTF-8 temp path"),
5466 )
5467 .expect("reopen");
5468 let progress = reopened
5469 .subscription_state("stable-subscription")
5470 .expect("persisted state");
5471 assert_eq!(progress.cursor, 41);
5472 assert!(progress.has_resume_token);
5473
5474 reopened
5475 .subscribe(
5476 "stable-subscription".to_owned(),
5477 "tasks".to_owned(),
5478 vec![(
5479 "project_id".to_owned(),
5480 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
5481 )],
5482 Some(r#"{"view":"v1"}"#.to_owned()),
5483 )
5484 .expect("canonical intent is idempotent");
5485 assert_eq!(
5486 reopened
5487 .subscription_state("stable-subscription")
5488 .expect("unchanged state")
5489 .cursor,
5490 progress.cursor
5491 );
5492
5493 for (table, scopes, params) in [
5494 (
5495 "tasks",
5496 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5497 Some(r#"{"view":"v1"}"#.to_owned()),
5498 ),
5499 (
5500 "tasks",
5501 vec![(
5502 "project_id".to_owned(),
5503 vec!["p2".to_owned(), "p1".to_owned()],
5504 )],
5505 Some(r#"{"view":"v2"}"#.to_owned()),
5506 ),
5507 (
5508 "docs",
5509 vec![
5510 ("org_id".to_owned(), vec!["o1".to_owned()]),
5511 ("projectId".to_owned(), vec!["p1".to_owned()]),
5512 ],
5513 Some(r#"{"view":"v1"}"#.to_owned()),
5514 ),
5515 ] {
5516 let error = reopened
5517 .subscribe(
5518 "stable-subscription".to_owned(),
5519 table.to_owned(),
5520 scopes,
5521 params,
5522 )
5523 .expect_err("identity rebind must fail");
5524 assert!(error.starts_with("client.subscription_intent_mismatch:"));
5525 assert_eq!(
5526 reopened
5527 .subscription_state("stable-subscription")
5528 .expect("unchanged state")
5529 .cursor,
5530 progress.cursor
5531 );
5532 }
5533
5534 drop(reopened);
5535 std::fs::remove_file(path).expect("remove temp database");
5536 }
5537
5538 #[test]
5539 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
5540 let path = std::env::temp_dir().join(format!(
5541 "syncular-satisfied-schema-floor-{}.db",
5542 uuid::Uuid::new_v4()
5543 ));
5544 let schema = json!({
5545 "version": 23,
5546 "tables": [{
5547 "name": "tasks",
5548 "primaryKey": "id",
5549 "columns": [
5550 { "name": "id", "type": "string", "nullable": false },
5551 { "name": "project_id", "type": "string", "nullable": false }
5552 ],
5553 "scopes": [{ "pattern": "project:{project_id}" }]
5554 }]
5555 });
5556
5557 {
5558 let mut first = SyncClient::open_path_with_identity(
5559 None,
5560 &schema,
5561 ClientLimits::default(),
5562 path.to_str().expect("UTF-8 temp path"),
5563 )
5564 .expect("first open");
5565 first
5566 .subscribe(
5567 "tasks".to_owned(),
5568 "tasks".to_owned(),
5569 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5570 None,
5571 )
5572 .expect("persist subscription");
5573 first.set_schema_floor(Some(SchemaFloor {
5574 required_schema_version: Some(22),
5575 latest_schema_version: Some(22),
5576 }));
5577 }
5578
5579 let mut reopened = SyncClient::open_path_with_identity(
5580 None,
5581 &schema,
5582 ClientLimits::default(),
5583 path.to_str().expect("UTF-8 temp path"),
5584 )
5585 .expect("reopen");
5586 assert!(reopened.schema_floor().is_none());
5587 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
5588 assert!(reopened.sync_needed());
5589 assert!(matches!(
5590 reopened.drain_sync_intents().as_slice(),
5591 [SyncIntent::Interactive]
5592 ));
5593 drop(reopened);
5594 std::fs::remove_file(path).expect("remove temp database");
5595 }
5596
5597 #[test]
5598 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
5599 let path = std::env::temp_dir().join(format!(
5600 "syncular-unsatisfied-schema-floor-{}.db",
5601 uuid::Uuid::new_v4()
5602 ));
5603 let schema = json!({
5604 "version": 1,
5605 "tables": [{
5606 "name": "tasks",
5607 "primaryKey": "id",
5608 "columns": [
5609 { "name": "id", "type": "string", "nullable": false },
5610 { "name": "project_id", "type": "string", "nullable": false }
5611 ],
5612 "scopes": [{ "pattern": "project:{project_id}" }]
5613 }]
5614 });
5615
5616 {
5617 let mut first = SyncClient::open_path_with_identity(
5618 None,
5619 &schema,
5620 ClientLimits::default(),
5621 path.to_str().expect("UTF-8 temp path"),
5622 )
5623 .expect("first open");
5624 first
5625 .subscribe(
5626 "tasks".to_owned(),
5627 "tasks".to_owned(),
5628 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5629 None,
5630 )
5631 .expect("persist subscription");
5632 first.set_schema_floor(Some(SchemaFloor {
5633 required_schema_version: Some(2),
5634 latest_schema_version: Some(2),
5635 }));
5636 }
5637
5638 let reopened = SyncClient::open_path_with_identity(
5639 None,
5640 &schema,
5641 ClientLimits::default(),
5642 path.to_str().expect("UTF-8 temp path"),
5643 )
5644 .expect("reopen");
5645 assert_eq!(
5646 reopened.schema_floor(),
5647 Some(&SchemaFloor {
5648 required_schema_version: Some(2),
5649 latest_schema_version: Some(2),
5650 })
5651 );
5652 assert!(!reopened.sync_needed());
5653 drop(reopened);
5654 std::fs::remove_file(path).expect("remove temp database");
5655 }
5656
5657 #[test]
5658 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
5659 let path = std::env::temp_dir().join(format!(
5660 "syncular-indexed-column-bump-{}.db",
5661 uuid::Uuid::new_v4()
5662 ));
5663 let old_schema = json!({
5664 "version": 1,
5665 "tables": [
5666 {
5667 "name": "tasks",
5668 "primaryKey": "id",
5669 "columns": [
5670 { "name": "id", "type": "string", "nullable": false },
5671 { "name": "project_id", "type": "string", "nullable": false }
5672 ],
5673 "scopes": [{ "pattern": "project:{project_id}" }]
5674 },
5675 {
5676 "name": "legacy",
5677 "primaryKey": "id",
5678 "columns": [
5679 { "name": "id", "type": "string", "nullable": false },
5680 { "name": "project_id", "type": "string", "nullable": false }
5681 ],
5682 "scopes": [{ "pattern": "project:{project_id}" }]
5683 }
5684 ]
5685 });
5686 {
5687 let mut first = SyncClient::open_path_with_identity(
5688 None,
5689 &old_schema,
5690 ClientLimits::default(),
5691 path.to_str().expect("UTF-8 temp path"),
5692 )
5693 .expect("open old schema");
5694 first
5695 .subscribe(
5696 "legacy-sub".to_owned(),
5697 "legacy".to_owned(),
5698 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5699 None,
5700 )
5701 .expect("persist legacy subscription");
5702 }
5703
5704 let new_schema = json!({
5705 "version": 2,
5706 "tables": [{
5707 "name": "tasks",
5708 "primaryKey": "id",
5709 "columns": [
5710 { "name": "id", "type": "string", "nullable": false },
5711 { "name": "project_id", "type": "string", "nullable": false },
5712 { "name": "facility_membership_id", "type": "string", "nullable": true }
5713 ],
5714 "scopes": [{ "pattern": "project:{project_id}" }],
5715 "indexes": [{
5716 "name": "tasks_by_membership",
5717 "columns": ["project_id", "facility_membership_id"],
5718 "unique": false
5719 }]
5720 }]
5721 });
5722 let upgraded = SyncClient::open_path_with_identity(
5723 None,
5724 &new_schema,
5725 ClientLimits::default(),
5726 path.to_str().expect("UTF-8 temp path"),
5727 )
5728 .expect("open upgraded schema");
5729 let columns = upgraded
5730 .conn
5731 .prepare("PRAGMA table_info(tasks)")
5732 .expect("prepare columns")
5733 .query_map([], |row| row.get::<_, String>(1))
5734 .expect("query columns")
5735 .collect::<Result<Vec<_>, _>>()
5736 .expect("collect columns");
5737 assert!(columns.contains(&"facility_membership_id".to_owned()));
5738 let index_count: i64 = upgraded
5739 .conn
5740 .query_row(
5741 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
5742 [],
5743 |row| row.get(0),
5744 )
5745 .expect("query index");
5746 assert_eq!(index_count, 1);
5747 assert!(upgraded.subscription_state("legacy-sub").is_none());
5748 drop(upgraded);
5749 std::fs::remove_file(path).expect("remove temp database");
5750 }
5751
5752 #[test]
5753 fn migrates_pre_envelope_outcome_journal_additively() {
5754 let path = std::env::temp_dir().join(format!(
5755 "syncular-outcome-migration-{}.db",
5756 uuid::Uuid::new_v4()
5757 ));
5758 let conn = Connection::open(&path).expect("open legacy database");
5759 conn.execute_batch(
5760 "CREATE TABLE _syncular_commit_outcomes (
5761 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5762 client_commit_id TEXT NOT NULL UNIQUE,
5763 status TEXT NOT NULL,
5764 recorded_at_ms INTEGER NOT NULL,
5765 results_json TEXT NOT NULL,
5766 resolution TEXT NOT NULL DEFAULT 'active',
5767 resolved_at_ms INTEGER,
5768 replacement_client_commit_id TEXT);",
5769 )
5770 .expect("create legacy outcome journal");
5771 drop(conn);
5772 let schema = json!({
5773 "version": 1,
5774 "tables": [{
5775 "name": "tasks",
5776 "primaryKey": "id",
5777 "columns": [
5778 { "name": "id", "type": "string", "nullable": false },
5779 { "name": "project_id", "type": "string", "nullable": false }
5780 ],
5781 "scopes": [{ "pattern": "project:{project_id}" }]
5782 }]
5783 });
5784 let client = SyncClient::open_path(
5785 "migration-native".to_owned(),
5786 &schema,
5787 ClientLimits::default(),
5788 path.to_str().expect("UTF-8 temp path"),
5789 )
5790 .expect("migrate database");
5791 let has_operations = client
5792 .conn
5793 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
5794 .expect("prepare table info")
5795 .query_map([], |row| row.get::<_, String>(1))
5796 .expect("query table info")
5797 .filter_map(Result::ok)
5798 .any(|column| column == "operations_json");
5799 assert!(has_operations);
5800 drop(client);
5801 std::fs::remove_file(path).expect("remove temp database");
5802 }
5803
5804 #[test]
5805 fn retained_rejection_survives_file_reopen_and_security_purge() {
5806 let path =
5807 std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
5808 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
5809 { "name": "id", "type": "string", "nullable": false },
5810 { "name": "project_id", "type": "string", "nullable": false },
5811 { "name": "title", "type": "string", "nullable": false }
5812 ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
5813 {
5814 let mut first = SyncClient::open_path(
5815 "retained-native".to_owned(),
5816 &schema,
5817 ClientLimits::default(),
5818 path.to_str().unwrap(),
5819 )
5820 .unwrap();
5821 first
5822 .write_base_row(
5823 "tasks",
5824 &vec![
5825 Some(ColumnValue::String("t1".into())),
5826 Some(ColumnValue::String("p1".into())),
5827 Some(ColumnValue::String("server".into())),
5828 ],
5829 1,
5830 )
5831 .unwrap();
5832 first.rebuild_overlay().expect("rebuild overlay");
5833 let operation = OutboxOp {
5834 table: "tasks".into(),
5835 row_id: "t1".into(),
5836 upsert: true,
5837 base_version: Some(1),
5838 values: Some(Map::from_iter([
5839 ("id".into(), json!("t1")),
5840 ("title".into(), json!("mine")),
5841 ])),
5842 };
5843 first
5844 .retain_failed_commit("retained", std::slice::from_ref(&operation))
5845 .unwrap();
5846 first
5847 .persist_commit_outcome(
5848 "retained",
5849 CommitOutcomeStatus::Rejected,
5850 &[CommitOperationOutcome::Error {
5851 rejection: RejectionRecord {
5852 client_commit_id: "retained".into(),
5853 op_index: 0,
5854 code: "validation.denied".into(),
5855 message: "denied".into(),
5856 retryable: false,
5857 details: None,
5858 operation: None,
5859 },
5860 }],
5861 Some(&[operation]),
5862 )
5863 .unwrap();
5864 first.rebuild_overlay().expect("rebuild overlay");
5865 }
5866 {
5867 let mut reopened = SyncClient::open_path(
5868 "retained-native".to_owned(),
5869 &schema,
5870 ClientLimits::default(),
5871 path.to_str().unwrap(),
5872 )
5873 .unwrap();
5874 let row = reopened
5875 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
5876 .expect("read visible row")
5877 .unwrap();
5878 assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
5879 assert_eq!(
5880 reopened
5881 .commit_outcome("retained")
5882 .unwrap()
5883 .unwrap()
5884 .retained_rows
5885 .unwrap()[0]
5886 .server_row
5887 .as_ref()
5888 .unwrap()["title"],
5889 json!("server")
5890 );
5891 reopened
5892 .conn
5893 .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
5894 .unwrap();
5895 reopened.rebuild_overlay().expect("rebuild overlay");
5896 assert!(reopened
5897 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
5898 .expect("read visible row")
5899 .is_none());
5900 let retained = reopened.commit_outcome("retained").unwrap().unwrap();
5901 assert!(retained.retained_rows.is_some());
5902 assert_eq!(
5903 retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
5904 json!("mine")
5905 );
5906 reopened
5907 .purge_local_data(&LocalDataPurgeInput {
5908 purge_id: "protected-purge".into(),
5909 targets: vec![LocalDataPurgeTarget {
5910 table: "tasks".into(),
5911 selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
5912 }],
5913 })
5914 .unwrap();
5915 assert!(reopened
5916 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
5917 .expect("read visible row")
5918 .is_none());
5919 assert!(reopened
5920 .commit_outcome("retained")
5921 .unwrap()
5922 .unwrap()
5923 .retained_rows
5924 .is_none());
5925 }
5926 std::fs::remove_file(path).unwrap();
5927 }
5928
5929 #[test]
5930 fn durable_conflict_outcome_and_resolution_survive_reopen() {
5931 let path = std::env::temp_dir().join(format!(
5932 "syncular-durable-outcome-{}.db",
5933 uuid::Uuid::new_v4()
5934 ));
5935 let schema = json!({
5936 "version": 1,
5937 "tables": [{
5938 "name": "tasks",
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 let conflict = ConflictRecord {
5948 client_commit_id: "losing-commit".to_owned(),
5949 op_index: 0,
5950 table: "tasks".to_owned(),
5951 row_id: "t1".to_owned(),
5952 code: "sync.version_conflict".to_owned(),
5953 message: "stale base version".to_owned(),
5954 server_version: 2,
5955 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
5956 conflict_columns: vec!["project_id".to_owned()],
5957 operation: Some(CommitOperation {
5958 table: "tasks".to_owned(),
5959 row_id: "t1".to_owned(),
5960 op: "upsert".to_owned(),
5961 base_version: Some(1),
5962 values: Some(Map::from_iter([
5963 ("id".into(), json!("t1")),
5964 ("project_id".into(), json!("p1")),
5965 ])),
5966 }),
5967 };
5968 let failed_operations = vec![
5969 OutboxOp {
5970 upsert: true,
5971 table: "tasks".to_owned(),
5972 row_id: "t1".to_owned(),
5973 base_version: Some(1),
5974 values: Some(Map::from_iter([
5975 ("id".into(), json!("t1")),
5976 ("project_id".into(), json!("p1")),
5977 ])),
5978 },
5979 OutboxOp {
5980 upsert: true,
5981 table: "tasks".to_owned(),
5982 row_id: "status-event-1".to_owned(),
5983 base_version: Some(0),
5984 values: Some(Map::from_iter([
5985 ("id".into(), json!("status-event-1")),
5986 ("project_id".into(), json!("p1")),
5987 ])),
5988 },
5989 ];
5990
5991 {
5992 let mut first = SyncClient::open_path(
5993 "durable-native".to_owned(),
5994 &schema,
5995 ClientLimits::default(),
5996 path.to_str().expect("UTF-8 temp path"),
5997 )
5998 .expect("first open");
5999 first
6000 .begin_observation("test_outcome")
6001 .expect("begin outcome");
6002 first
6003 .persist_commit_outcome(
6004 "losing-commit",
6005 CommitOutcomeStatus::Conflict,
6006 &[CommitOperationOutcome::Conflict {
6007 conflict: conflict.clone(),
6008 }],
6009 Some(&failed_operations),
6010 )
6011 .expect("persist outcome");
6012 first.conflicts.push(conflict);
6013 first
6014 .finish_observation(
6015 "test_outcome",
6016 ChangeAccumulator {
6017 conflicts: true,
6018 outcomes: true,
6019 ..ChangeAccumulator::default()
6020 },
6021 )
6022 .expect("commit outcome");
6023 }
6024
6025 {
6026 let mut reopened = SyncClient::open_path(
6027 "durable-native".to_owned(),
6028 &schema,
6029 ClientLimits::default(),
6030 path.to_str().expect("UTF-8 temp path"),
6031 )
6032 .expect("reopen");
6033 assert_eq!(reopened.conflicts().len(), 1);
6034 assert_eq!(
6035 reopened.conflicts()[0].conflict_columns,
6036 vec!["project_id".to_owned()],
6037 "conflictColumns survives the journal round trip"
6038 );
6039 let outcome = reopened
6040 .commit_outcome("losing-commit")
6041 .expect("read outcome")
6042 .expect("outcome");
6043 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
6044 let operations = outcome.operations.expect("aggregate envelope");
6045 assert_eq!(operations.len(), 2);
6046 assert_eq!(operations[1].row_id, "status-event-1");
6047 let resolved = reopened
6048 .resolve_commit_outcome(ResolveCommitOutcomeInput {
6049 client_commit_id: "losing-commit".to_owned(),
6050 resolution: CommitOutcomeResolution::ResolvedKeepServer,
6051 replacement_client_commit_id: None,
6052 })
6053 .expect("resolve");
6054 assert_eq!(
6055 resolved.resolution,
6056 CommitOutcomeResolution::ResolvedKeepServer
6057 );
6058 assert!(reopened.conflicts().is_empty());
6059 }
6060
6061 let reopened = SyncClient::open_path(
6062 "durable-native".to_owned(),
6063 &schema,
6064 ClientLimits::default(),
6065 path.to_str().expect("UTF-8 temp path"),
6066 )
6067 .expect("second reopen");
6068 assert!(reopened.conflicts().is_empty());
6069 assert_eq!(
6070 reopened
6071 .commit_outcome("losing-commit")
6072 .expect("read outcome")
6073 .expect("outcome")
6074 .resolution,
6075 CommitOutcomeResolution::ResolvedKeepServer
6076 );
6077 drop(reopened);
6078 std::fs::remove_file(path).expect("remove temp database");
6079 }
6080
6081 #[test]
6082 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
6083 let path =
6084 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6085 let schema = json!({
6086 "version": 1,
6087 "tables": [{
6088 "name": "tasks",
6089 "primaryKey": "id",
6090 "columns": [
6091 { "name": "id", "type": "string", "nullable": false },
6092 { "name": "project_id", "type": "string", "nullable": false }
6093 ],
6094 "scopes": [{ "pattern": "project:{project_id}" }]
6095 }]
6096 });
6097 let mut client = SyncClient::open_path_with_identity(
6098 Some("sidecar-client".to_owned()),
6099 &schema,
6100 ClientLimits::default(),
6101 path.to_str().expect("UTF-8 temp path"),
6102 )
6103 .expect("open owner");
6104 let base = WindowBase {
6105 table: "tasks".to_owned(),
6106 variable: "project_id".to_owned(),
6107 fixed_scopes: Vec::new(),
6108 params: None,
6109 };
6110 client
6111 .set_window(&base, &["one".to_owned()])
6112 .expect("set window");
6113 client
6114 .mutate(vec![Mutation::Upsert {
6115 table: "tasks".to_owned(),
6116 values: Map::from_iter([
6117 ("id".to_owned(), Value::from("t1")),
6118 ("project_id".to_owned(), Value::from("one")),
6119 ]),
6120 base_version: None,
6121 }])
6122 .expect("local mutate");
6123
6124 let coverage = [WindowCoverage {
6125 base,
6126 units: vec!["one".to_owned(), "missing".to_owned()],
6127 }];
6128 let owner = client
6129 .query_snapshot(
6130 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6131 &[],
6132 &coverage,
6133 None,
6134 )
6135 .expect("owner snapshot");
6136 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6137 let sidecar = reader
6138 .query_snapshot(
6139 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6140 &[],
6141 &coverage,
6142 )
6143 .expect("sidecar snapshot");
6144
6145 assert_eq!(sidecar.revision, owner.revision);
6146 assert_eq!(sidecar.rows, owner.rows);
6147 assert_eq!(
6148 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
6149 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
6150 );
6151 assert_eq!(sidecar.revision, "2");
6152 assert_eq!(sidecar.rows[0]["id"], "t1");
6153 assert_eq!(sidecar.rows[0]["server_version"], -1);
6154 assert!(!sidecar.coverage.complete);
6155 assert_eq!(sidecar.coverage.pending.len(), 1);
6156 assert_eq!(sidecar.coverage.missing.len(), 1);
6157
6158 drop(reader);
6159 drop(client);
6160 std::fs::remove_file(path).expect("remove temp database");
6161 }
6162
6163 #[test]
6164 fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
6165 let path =
6166 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6167 let schema = json!({
6168 "version": 1,
6169 "tables": [{
6170 "name": "tasks",
6171 "primaryKey": "id",
6172 "columns": [
6173 { "name": "id", "type": "string", "nullable": false },
6174 { "name": "project_id", "type": "string", "nullable": false }
6175 ],
6176 "scopes": [{ "pattern": "project:{project_id}" }]
6177 }]
6178 });
6179 let mut client = SyncClient::open_path_with_identity(
6180 Some("sidecar-catchup".to_owned()),
6181 &schema,
6182 ClientLimits::default(),
6183 path.to_str().expect("UTF-8 temp path"),
6184 )
6185 .expect("open owner");
6186 client
6187 .subscribe(
6188 "s1".into(),
6189 "tasks".into(),
6190 vec![("project_id".into(), vec!["one".into()])],
6191 None,
6192 )
6193 .expect("subscribe");
6194 let commit_id = client
6195 .mutate(vec![Mutation::Upsert {
6196 table: "tasks".to_owned(),
6197 values: Map::from_iter([
6198 ("id".to_owned(), Value::from("t1")),
6199 ("project_id".to_owned(), Value::from("one")),
6200 ]),
6201 base_version: None,
6202 }])
6203 .expect("mutate");
6204 let revision = client.local_revision().to_string();
6205
6206 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6207 assert_eq!(reader.local_revision().expect("revision"), revision);
6208
6209 let read = reader
6210 .snapshot_read(&SnapshotReadRequest {
6211 statements: vec![
6212 SnapshotStatement {
6213 sql: "SELECT 1 AS n",
6214 params: &[],
6215 },
6216 SnapshotStatement {
6217 sql: "SELECT id FROM tasks ORDER BY id",
6218 params: &[],
6219 },
6220 ],
6221 coverage: &[],
6222 subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
6223 commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
6224 })
6225 .expect("snapshot read");
6226 assert_eq!(read.revision, revision);
6227 assert_eq!(read.queries.len(), 2);
6228 assert_eq!(read.queries[0][0]["n"], 1);
6229 assert_eq!(read.queries[1][0]["id"], "t1");
6230 match &read.subscriptions[0] {
6231 SubscriptionCatchup::Known {
6232 status,
6233 cursor,
6234 bootstrap_complete,
6235 known_pending_pages,
6236 ..
6237 } => {
6238 assert_eq!(status, "active");
6239 assert_eq!(*cursor, -1);
6240 assert!(!bootstrap_complete);
6241 assert!(known_pending_pages);
6242 }
6243 other => panic!("expected a known subscription: {other:?}"),
6244 }
6245 assert!(matches!(
6246 &read.subscriptions[1],
6247 SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
6248 ));
6249 assert!(matches!(
6250 &read.deliveries[0],
6251 CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
6252 ));
6253 assert!(matches!(
6254 &read.deliveries[1],
6255 CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
6256 ));
6257
6258 assert!(reader
6260 .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
6261 .is_err());
6262 assert_eq!(
6263 reader.local_revision().expect("revision after rollback"),
6264 revision
6265 );
6266 assert!(reader
6268 .query_snapshot("DELETE FROM tasks", &[], &[])
6269 .is_err());
6270
6271 client
6273 .conn
6274 .execute(
6275 "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
6276 [],
6277 )
6278 .expect("corrupt revision");
6279 let failure = reader.local_revision().expect_err("corrupt revision fails");
6280 assert_eq!(failure.code, Some("sync.local_corrupt"));
6281
6282 drop(reader);
6283 drop(client);
6284 std::fs::remove_file(path).expect("remove temp database");
6285 }
6286
6287 #[test]
6288 fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
6289 let path =
6290 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6291 let schema = json!({
6292 "version": 1,
6293 "tables": [{
6294 "name": "tasks",
6295 "primaryKey": "id",
6296 "columns": [
6297 { "name": "id", "type": "string", "nullable": false },
6298 { "name": "project_id", "type": "string", "nullable": false }
6299 ],
6300 "scopes": [{ "pattern": "project:{project_id}" }]
6301 }]
6302 });
6303 let mut client = SyncClient::open_path_with_identity(
6304 Some("sidecar-delivery".to_owned()),
6305 &schema,
6306 ClientLimits::default(),
6307 path.to_str().expect("UTF-8 temp path"),
6308 )
6309 .expect("open owner");
6310 client.begin_observation("test_delivery").expect("observe");
6311 let operations = [OutboxOp {
6312 upsert: true,
6313 table: "tasks".to_owned(),
6314 row_id: "t1".to_owned(),
6315 base_version: Some(1),
6316 values: Some(Map::from_iter([
6317 ("id".into(), json!("t1")),
6318 ("project_id".into(), json!("p1")),
6319 ])),
6320 }];
6321 client
6322 .persist_commit_outcome(
6323 "conflict-commit",
6324 CommitOutcomeStatus::Conflict,
6325 &[CommitOperationOutcome::Conflict {
6326 conflict: ConflictRecord {
6327 client_commit_id: "conflict-commit".to_owned(),
6328 op_index: 0,
6329 table: "tasks".to_owned(),
6330 row_id: "t1".to_owned(),
6331 code: "sync.version_conflict".to_owned(),
6332 message: "stale base version".to_owned(),
6333 server_version: 2,
6334 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6335 conflict_columns: vec!["project_id".to_owned()],
6336 operation: None,
6337 },
6338 }],
6339 Some(&operations),
6340 )
6341 .expect("persist conflict");
6342 client
6343 .persist_commit_outcome(
6344 "rejected-commit",
6345 CommitOutcomeStatus::Rejected,
6346 &[CommitOperationOutcome::Error {
6347 rejection: RejectionRecord {
6348 client_commit_id: "rejected-commit".to_owned(),
6349 op_index: 0,
6350 code: "sync.unknown_table".to_owned(),
6351 message: "unknown table".to_owned(),
6352 retryable: false,
6353 details: None,
6354 operation: None,
6355 },
6356 }],
6357 Some(&operations),
6358 )
6359 .expect("persist rejection");
6360 client
6361 .finish_observation(
6362 "test_delivery",
6363 ChangeAccumulator {
6364 conflicts: true,
6365 outcomes: true,
6366 ..ChangeAccumulator::default()
6367 },
6368 )
6369 .expect("commit outcomes");
6370
6371 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6372 match reader.commit_delivery("conflict-commit").expect("conflict") {
6373 CommitDelivery::Known { outcome, .. } => {
6374 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
6375 assert!(matches!(
6376 outcome.results.as_slice(),
6377 [CommitOperationOutcome::Conflict { conflict }]
6378 if conflict.code == "sync.version_conflict"
6379 && conflict.server_version == 2
6380 ));
6381 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
6382 assert!(
6383 outcome.retained_rows.is_none(),
6384 "the sidecar reports the persisted view, not owner-derived retained rows"
6385 );
6386 }
6387 other => panic!("expected a known conflict: {other:?}"),
6388 }
6389 match reader
6390 .commit_delivery("rejected-commit")
6391 .expect("rejection")
6392 {
6393 CommitDelivery::Known { outcome, .. } => {
6394 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
6395 assert!(matches!(
6396 outcome.results.as_slice(),
6397 [CommitOperationOutcome::Error { rejection }]
6398 if rejection.code == "sync.unknown_table"
6399 ));
6400 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
6401 assert!(outcome.retained_rows.is_none());
6402 }
6403 other => panic!("expected a known rejection: {other:?}"),
6404 }
6405 drop(reader);
6406 drop(client);
6407 std::fs::remove_file(path).expect("remove temp database");
6408 }
6409
6410 #[test]
6411 fn snapshot_read_isolates_a_concurrent_writer_commit() {
6412 use std::sync::mpsc::channel;
6413 let path = std::env::temp_dir().join(format!(
6414 "syncular-read-concurrent-{}.db",
6415 uuid::Uuid::new_v4()
6416 ));
6417 let schema = json!({
6418 "version": 1,
6419 "tables": [{
6420 "name": "tasks",
6421 "primaryKey": "id",
6422 "columns": [
6423 { "name": "id", "type": "string", "nullable": false },
6424 { "name": "project_id", "type": "string", "nullable": false }
6425 ],
6426 "scopes": [{ "pattern": "project:{project_id}" }]
6427 }]
6428 });
6429 let mut client = SyncClient::open_path_with_identity(
6430 Some("sidecar-concurrent".to_owned()),
6431 &schema,
6432 ClientLimits::default(),
6433 path.to_str().expect("UTF-8 temp path"),
6434 )
6435 .expect("open owner");
6436 client
6437 .mutate(vec![Mutation::Upsert {
6438 table: "tasks".to_owned(),
6439 values: Map::from_iter([
6440 ("id".to_owned(), Value::from("t1")),
6441 ("project_id".to_owned(), Value::from("one")),
6442 ]),
6443 base_version: None,
6444 }])
6445 .expect("seed row");
6446 let revision_before = client.local_revision().to_string();
6447
6448 let (ready_tx, ready_rx) = channel::<()>();
6451 let (done_tx, done_rx) = channel::<()>();
6452 let writer_path = path.to_string_lossy().into_owned();
6453 let writer_schema = schema.clone();
6454 let writer = std::thread::spawn(move || {
6455 let mut writer = SyncClient::open_path_with_identity(
6456 Some("sidecar-concurrent".to_owned()),
6457 &writer_schema,
6458 ClientLimits::default(),
6459 &writer_path,
6460 )
6461 .expect("open writer");
6462 ready_rx.recv().expect("reader signalled");
6463 writer
6464 .mutate(vec![Mutation::Upsert {
6465 table: "tasks".to_owned(),
6466 values: Map::from_iter([
6467 ("id".to_owned(), Value::from("t2")),
6468 ("project_id".to_owned(), Value::from("one")),
6469 ]),
6470 base_version: None,
6471 }])
6472 .expect("writer commit");
6473 let _ = done_tx.send(());
6474 });
6475 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6476 SNAPSHOT_READ_HOOK.with(|slot| {
6477 *slot.borrow_mut() = Some(Box::new(move || {
6478 ready_tx.send(()).expect("signal writer");
6479 done_rx.recv().expect("writer finished");
6480 }));
6481 });
6482 let read = reader
6483 .snapshot_read(&SnapshotReadRequest {
6484 statements: vec![
6485 SnapshotStatement {
6486 sql: "SELECT count(*) AS n FROM tasks",
6487 params: &[],
6488 },
6489 SnapshotStatement {
6490 sql: "SELECT count(*) AS n FROM tasks",
6491 params: &[],
6492 },
6493 ],
6494 coverage: &[],
6495 subscriptions: Vec::new(),
6496 commit_ids: Vec::new(),
6497 })
6498 .expect("snapshot read");
6499 assert_eq!(
6500 read.queries[0][0]["n"], 1,
6501 "first statement sees the pre-commit row"
6502 );
6503 assert_eq!(
6504 read.queries[1][0]["n"], 1,
6505 "a later statement keeps the one read snapshot"
6506 );
6507 assert_eq!(
6508 read.revision, revision_before,
6509 "the read reports the snapshot revision, not the writer's"
6510 );
6511 writer.join().expect("join writer");
6512
6513 assert_ne!(
6514 reader.local_revision().expect("revision after commit"),
6515 revision_before,
6516 "the next read observes the committed revision"
6517 );
6518 let after = reader
6519 .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
6520 .expect("count after commit");
6521 assert_eq!(
6522 after.rows[0]["n"], 2,
6523 "the committed row is visible afterwards"
6524 );
6525
6526 drop(reader);
6527 drop(client);
6528 std::fs::remove_file(path).expect("remove temp database");
6529 }
6530
6531 #[test]
6532 fn snapshot_read_rejects_every_non_canonical_revision() {
6533 let path = std::env::temp_dir().join(format!(
6534 "syncular-read-revision-{}.db",
6535 uuid::Uuid::new_v4()
6536 ));
6537 let schema = json!({
6538 "version": 1,
6539 "tables": [{
6540 "name": "tasks",
6541 "primaryKey": "id",
6542 "columns": [
6543 { "name": "id", "type": "string", "nullable": false },
6544 { "name": "project_id", "type": "string", "nullable": false }
6545 ],
6546 "scopes": [{ "pattern": "project:{project_id}" }]
6547 }]
6548 });
6549 let client = SyncClient::open_path_with_identity(
6550 Some("sidecar-revision".to_owned()),
6551 &schema,
6552 ClientLimits::default(),
6553 path.to_str().expect("UTF-8 temp path"),
6554 )
6555 .expect("open owner");
6556 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6557 for bad in [
6558 "+1",
6559 "01",
6560 "-1",
6561 " 1",
6562 "1 ",
6563 "18446744073709551616",
6564 "1.0",
6565 "abc",
6566 "",
6567 ] {
6568 client
6569 .conn
6570 .execute(
6571 "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
6572 rusqlite::params![bad],
6573 )
6574 .expect("corrupt revision");
6575 let failure = reader.local_revision().expect_err("corrupt marker fails");
6576 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
6577 }
6578 for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
6579 client
6580 .conn
6581 .execute_batch(&format!(
6582 "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
6583 ))
6584 .expect("corrupt stored revision type");
6585 assert_eq!(
6586 reader.local_revision().expect_err("invalid SQL field").code,
6587 Some("sync.local_corrupt")
6588 );
6589 }
6590 client
6592 .conn
6593 .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
6594 .expect("delete marker");
6595 assert_eq!(reader.local_revision().expect("legacy zero"), "0");
6596
6597 drop(reader);
6598 drop(client);
6599 std::fs::remove_file(path).expect("remove temp database");
6600 }
6601
6602 #[test]
6603 fn snapshot_read_rejects_corrupt_subscription_state() {
6604 let path = std::env::temp_dir().join(format!(
6605 "syncular-read-substate-{}.db",
6606 uuid::Uuid::new_v4()
6607 ));
6608 let schema = json!({
6609 "version": 1,
6610 "tables": [{
6611 "name": "tasks",
6612 "primaryKey": "id",
6613 "columns": [
6614 { "name": "id", "type": "string", "nullable": false },
6615 { "name": "project_id", "type": "string", "nullable": false }
6616 ],
6617 "scopes": [{ "pattern": "project:{project_id}" }]
6618 }]
6619 });
6620 let mut client = SyncClient::open_path_with_identity(
6621 Some("sidecar-substate".to_owned()),
6622 &schema,
6623 ClientLimits::default(),
6624 path.to_str().expect("UTF-8 temp path"),
6625 )
6626 .expect("open owner");
6627 client
6628 .subscribe(
6629 "s1".into(),
6630 "tasks".into(),
6631 vec![("project_id".into(), vec!["one".into()])],
6632 None,
6633 )
6634 .expect("subscribe");
6635 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6636 for bad in [
6637 "not json".to_owned(),
6638 "{}".to_owned(),
6639 json!({ "status": "bogus", "cursor": 0 }).to_string(),
6640 json!({ "status": "active", "cursor": "x" }).to_string(),
6641 json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
6642 json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
6643 ] {
6644 client
6645 .conn
6646 .execute(
6647 "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
6648 rusqlite::params![bad],
6649 )
6650 .expect("corrupt subscription state");
6651 let failure = reader
6652 .subscription_catchup("s1")
6653 .expect_err("corrupt state fails");
6654 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
6655 }
6656
6657 for assignment in [
6658 "tbl=X'7461736b73'",
6659 "state_json=X'7b7d'",
6660 "state_json=CAST(X'FF' AS TEXT)",
6661 ] {
6662 client.conn.execute_batch(&format!(
6663 "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
6664 )).expect("corrupt stored subscription type");
6665 assert_eq!(
6666 reader
6667 .subscription_catchup("s1")
6668 .expect_err("invalid SQL field")
6669 .code,
6670 Some("sync.local_corrupt")
6671 );
6672 }
6673
6674 drop(reader);
6675 drop(client);
6676 std::fs::remove_file(path).expect("remove temp database");
6677 }
6678
6679 #[test]
6680 fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
6681 let path = std::env::temp_dir().join(format!(
6682 "syncular-read-rollback-{}.db",
6683 uuid::Uuid::new_v4()
6684 ));
6685 let schema = json!({
6686 "version": 1,
6687 "tables": [{
6688 "name": "tasks",
6689 "primaryKey": "id",
6690 "columns": [
6691 { "name": "id", "type": "string", "nullable": false },
6692 { "name": "project_id", "type": "string", "nullable": false }
6693 ],
6694 "scopes": [{ "pattern": "project:{project_id}" }]
6695 }]
6696 });
6697 let mut client = SyncClient::open_path_with_identity(
6698 Some("sidecar-rollback".to_owned()),
6699 &schema,
6700 ClientLimits::default(),
6701 path.to_str().expect("UTF-8 temp path"),
6702 )
6703 .expect("open owner");
6704 let commit_id = client
6705 .mutate(vec![Mutation::Upsert {
6706 table: "tasks".to_owned(),
6707 values: Map::from_iter([
6708 ("id".to_owned(), Value::from("t1")),
6709 ("project_id".to_owned(), Value::from("one")),
6710 ]),
6711 base_version: None,
6712 }])
6713 .expect("seed row");
6714 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6715 let revision = reader.local_revision().expect("revision");
6716
6717 let failure = reader
6720 .snapshot_read(&SnapshotReadRequest {
6721 statements: vec![
6722 SnapshotStatement {
6723 sql: "SELECT id FROM tasks",
6724 params: &[],
6725 },
6726 SnapshotStatement {
6727 sql: "SELECT * FROM does_not_exist",
6728 params: &[],
6729 },
6730 ],
6731 coverage: &[],
6732 subscriptions: Vec::new(),
6733 commit_ids: vec![commit_id.clone()],
6734 })
6735 .expect_err("second statement fails");
6736 assert_eq!(failure.code, None);
6737 assert_eq!(
6738 reader.local_revision().expect("revision after rollback"),
6739 revision,
6740 "a rolled-back read consumes no revision"
6741 );
6742 assert!(
6743 !matches!(
6744 reader
6745 .commit_delivery(&commit_id)
6746 .expect("delivery after rollback"),
6747 CommitDelivery::Unknown { .. }
6748 ),
6749 "the same reader serves the next read after releasing the connection"
6750 );
6751
6752 let guard = reader
6754 .snapshot_read(&SnapshotReadRequest {
6755 statements: vec![SnapshotStatement {
6756 sql: "DELETE FROM tasks",
6757 params: &[],
6758 }],
6759 coverage: &[],
6760 subscriptions: Vec::new(),
6761 commit_ids: Vec::new(),
6762 })
6763 .expect_err("write guard");
6764 assert!(guard.message.contains("read-only"), "{}", guard.message);
6765
6766 drop(reader);
6767 drop(client);
6768 std::fs::remove_file(path).expect("remove temp database");
6769 }
6770
6771 #[test]
6772 fn owned_query_failures_classify_order_bound_and_clear() {
6773 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
6774 rusqlite::ffi::Error::new(266),
6775 Some("private SQLite prose".to_owned()),
6776 ));
6777 assert_eq!(io.code, Some("client.storage_io"));
6778 assert_eq!(io.sqlite_code, Some(266));
6779 assert_eq!(io.message, "local SQLite storage I/O failed");
6780 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
6781 rusqlite::ffi::Error::new(26),
6782 None,
6783 ));
6784 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
6785 assert!(!io.retryable());
6786 assert!(!corrupt.retryable());
6787
6788 for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
6791 let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
6792 rusqlite::ffi::Error::new(raw),
6793 Some("database is locked".to_owned()),
6794 ));
6795 assert_eq!(busy.code, Some("client.storage_busy"));
6796 assert_eq!(busy.sqlite_code, Some(extended));
6797 assert_eq!(busy.message, "local SQLite storage is busy");
6798 assert!(busy.retryable());
6799 }
6800 let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
6801 rusqlite::ffi::Error::new(1),
6802 Some("generic SQLite prose".to_owned()),
6803 ));
6804 assert_eq!(unrelated.code, None);
6805 assert!(!unrelated.retryable());
6806
6807 let mut client = client();
6808 let generic = QueryReadFailure::from("private query prose".to_owned());
6809 client.record_query_read(
6810 QueryOwner {
6811 id: "first",
6812 tables: &["tasks", "tasks"],
6813 },
6814 Some(&generic),
6815 );
6816 client.record_query_read(
6817 QueryOwner {
6818 id: "second",
6819 tables: &["tasks"],
6820 },
6821 Some(&generic),
6822 );
6823 client.record_query_read(
6824 QueryOwner {
6825 id: "first",
6826 tables: &["docs"],
6827 },
6828 Some(&io),
6829 );
6830 let failures = &client
6831 .diagnostics_snapshot(&Default::default())
6832 .expect("diagnostics")
6833 .query_failures;
6834 assert_eq!(
6835 failures
6836 .iter()
6837 .map(|entry| entry.id.as_str())
6838 .collect::<Vec<_>>(),
6839 vec!["second", "first"]
6840 );
6841 assert_eq!(failures[1].tables, vec!["docs"]);
6842 assert_eq!(failures[1].code, "client.storage_io");
6843
6844 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
6845 client.record_query_read(
6846 QueryOwner {
6847 id: &format!("query-{index}"),
6848 tables: &["tasks"],
6849 },
6850 Some(&generic),
6851 );
6852 }
6853 let failures = &client
6854 .diagnostics_snapshot(&Default::default())
6855 .expect("bounded diagnostics")
6856 .query_failures;
6857 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
6858 assert_eq!(failures.last().expect("last failure").id, "query-256");
6859
6860 client
6861 .query_snapshot(
6862 "SELECT id FROM tasks",
6863 &[],
6864 &[],
6865 Some(QueryOwner {
6866 id: "query-256",
6867 tables: &["tasks"],
6868 }),
6869 )
6870 .expect("successful owned read");
6871 assert!(client
6872 .diagnostics_snapshot(&Default::default())
6873 .expect("cleared diagnostics")
6874 .query_failures
6875 .iter()
6876 .all(|entry| entry.id != "query-256"));
6877 }
6878
6879 #[test]
6880 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
6881 let mut client = client();
6882 let image = Connection::open_in_memory().unwrap();
6883 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
6884 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
6885 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
6886 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
6887 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
6888 let table = client.schema.table("tasks").unwrap().clone();
6889 let scopes = vec![("project_id".into(), vec!["p1".into()])];
6890 assert!(client
6891 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
6892 .is_err());
6893 assert!(client.conn.is_autocommit());
6894 assert_eq!(
6895 client
6896 .query("SELECT count(*) AS n FROM tasks", &[])
6897 .unwrap()[0]["n"],
6898 1024
6899 );
6900 assert_eq!(client.drain_change_batches().len(), 1);
6901 image
6902 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
6903 .unwrap();
6904 assert!(matches!(
6905 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
6906 Ok(2050)
6907 ));
6908 assert_eq!(
6909 client
6910 .query("SELECT count(*) AS n FROM tasks", &[])
6911 .unwrap()[0]["n"],
6912 2050
6913 );
6914 assert_eq!(client.drain_change_batches().len(), 3);
6915 }
6916
6917 #[test]
6918 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
6919 let mut client = client();
6920 let base = WindowBase {
6921 table: "tasks".into(),
6922 variable: "project_id".into(),
6923 fixed_scopes: Vec::new(),
6924 params: None,
6925 };
6926 client
6927 .set_window(&base, &["p1".into(), "p2".into()])
6928 .unwrap();
6929 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
6930 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
6931 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
6932 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
6933 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
6934 client.drain_change_batches();
6935 assert!(client.set_window(&base, &["p2".into()]).is_err());
6936 assert!(client.conn.is_autocommit());
6937 assert_eq!(
6938 client
6939 .query("SELECT count(*) AS n FROM tasks", &[])
6940 .unwrap()[0]["n"],
6941 1027
6942 );
6943 assert_eq!(client.window_state(&base).units, vec!["p2"]);
6944 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
6945 assert_eq!(client.drain_change_batches().len(), 1);
6946 client
6947 .conn
6948 .execute_batch("DROP TRIGGER interrupt_evict")
6949 .unwrap();
6950 client.drain_pending_evictions().unwrap();
6951 assert!(client.load_pending_evictions().unwrap().is_empty());
6952 assert_eq!(
6953 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
6954 "held"
6955 );
6956 assert_eq!(client.drain_change_batches().len(), 2);
6957 }
6958
6959 #[test]
6960 fn eviction_marker_failures_roll_back_window_changes() {
6961 let mut client = client();
6962 let base = WindowBase {
6963 table: "tasks".into(),
6964 variable: "project_id".into(),
6965 fixed_scopes: Vec::new(),
6966 params: None,
6967 };
6968 client.set_window(&base, &["p1".into()]).unwrap();
6969 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
6970 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
6971 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
6972 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
6973 let revision = client.local_revision();
6974 assert!(client.set_window(&base, &[]).is_err());
6975 assert!(client.conn.is_autocommit());
6976 assert_eq!(client.local_revision(), revision);
6977 assert_eq!(
6978 client
6979 .query("SELECT count(*) AS n FROM tasks", &[])
6980 .unwrap()[0]["n"],
6981 2050
6982 );
6983 assert_eq!(client.window_state(&base).units, vec!["p1"]);
6984 client.conn.execute_batch("DROP TRIGGER fail_marker;
6985 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
6986 assert!(client.set_window(&base, &[]).is_err());
6987 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
6988 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
6989 assert!(client.drain_pending_evictions().is_err());
6990 assert!(client.conn.is_autocommit());
6991 assert_eq!(
6992 client
6993 .query("SELECT count(*) AS n FROM tasks", &[])
6994 .unwrap()[0]["n"],
6995 2
6996 );
6997 assert!(client.set_window(&base, &["p1".into()]).is_err());
6998 assert!(client.conn.is_autocommit());
6999 assert!(client.window_state(&base).units.is_empty());
7000 assert!(client.subs.is_empty());
7001 assert_eq!(
7002 client
7003 .query(
7004 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
7005 &[]
7006 )
7007 .unwrap()[0]["n"],
7008 1
7009 );
7010 client
7011 .conn
7012 .execute_batch("DROP TRIGGER fail_marker")
7013 .unwrap();
7014 client.set_window(&base, &["p1".into()]).unwrap();
7015 assert_eq!(client.window_state(&base).units, vec!["p1"]);
7016 }
7017
7018 #[test]
7019 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
7020 let mut client = client();
7021 client
7022 .conn
7023 .execute(
7024 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
7025 [],
7026 )
7027 .unwrap();
7028 assert_eq!(
7029 client.drain_pending_evictions().unwrap_err(),
7030 "sync.local_corrupt: invalid pending eviction scopes"
7031 );
7032 assert!(client.conn.is_autocommit());
7033 assert!(client.enqueue_startup_sync_if_needed().is_err());
7034 }
7035
7036 #[test]
7037 fn image_chunks_reject_null_primary_keys() {
7038 let mut client = client();
7039 let image = Connection::open_in_memory().unwrap();
7040 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7041 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
7042 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
7043 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7044 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
7045 let table = client.schema.table("tasks").unwrap().clone();
7046 assert!(client
7047 .apply_sqlite_image(
7048 &image,
7049 &table,
7050 true,
7051 &[("project_id".into(), vec!["p1".into()])],
7052 1025,
7053 7,
7054 "digest"
7055 )
7056 .is_err());
7057 assert!(client.conn.is_autocommit());
7058 assert_eq!(
7059 client
7060 .query("SELECT count(*) AS n FROM tasks", &[])
7061 .unwrap()[0]["n"],
7062 0
7063 );
7064 }
7065
7066 #[test]
7067 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
7068 for images in [false, true] {
7069 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7070 "name":name,"primaryKey":"id","columns":[
7071 {"name":"id","type":"string","nullable":false},
7072 {"name":"project_id","type":"string","nullable":false},
7073 {"name":"title","type":"string","nullable":false}],
7074 "scopes":[{"pattern":"project:{project_id}"}],
7075 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
7076 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7077 })))});
7078 let mut actual =
7079 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
7080 let mut reference =
7081 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
7082 let scopes = vec![("project_id".into(), vec!["p1".into()])];
7083 for client in [&mut actual, &mut reference] {
7084 client
7085 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
7086 .unwrap();
7087 client.subs[0].effective = Some(scopes.clone());
7088 for (table, id, title) in [
7089 ("tasks", "a", "original"),
7090 ("tasks", "b", "occupied"),
7091 ("tasks", "c", "deleted"),
7092 ("notes", "n", "untouched"),
7093 ] {
7094 client
7095 .write_base_row(
7096 table,
7097 &vec![
7098 Some(ColumnValue::String(id.into())),
7099 Some(ColumnValue::String("p1".into())),
7100 Some(ColumnValue::String(title.into())),
7101 ],
7102 1,
7103 )
7104 .unwrap();
7105 }
7106 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7107 client
7108 .mutate(vec![
7109 Mutation::Upsert {
7110 table: "tasks".into(),
7111 values: Map::from_iter([
7112 ("id".into(), Value::from("a")),
7113 ("project_id".into(), Value::from("p1")),
7114 ("title".into(), Value::from("available")),
7115 ]),
7116 base_version: None,
7117 },
7118 Mutation::Delete {
7119 table: "tasks".into(),
7120 row_id: "c".into(),
7121 base_version: None,
7122 },
7123 Mutation::Upsert {
7124 table: "tasks".into(),
7125 values: Map::from_iter([
7126 ("id".into(), Value::from("local")),
7127 ("project_id".into(), Value::from("p1")),
7128 ("title".into(), Value::from("local value")),
7129 ]),
7130 base_version: None,
7131 },
7132 Mutation::Upsert {
7133 table: "notes".into(),
7134 values: Map::from_iter([
7135 ("id".into(), Value::from("n")),
7136 ("project_id".into(), Value::from("p1")),
7137 ("title".into(), Value::from("pending note")),
7138 ]),
7139 base_version: None,
7140 },
7141 ])
7142 .unwrap();
7143 }
7144 let pending = actual.pending_commit_ids();
7145 actual.overlay_rebuild_count.set(0);
7146 for (id, title) in [
7147 ("b", "available"),
7148 ("b", "occupied"),
7149 ("a", "server original"),
7150 ("d", "local value"),
7151 ("d", "released"),
7152 ("c", "redelivered"),
7153 ] {
7154 let table = actual.schema.table("tasks").unwrap().clone();
7155 let values = vec![
7156 Some(ColumnValue::String(id.into())),
7157 Some(ColumnValue::String("p1".into())),
7158 Some(ColumnValue::String(title.into())),
7159 ];
7160 if images {
7161 let image = Connection::open_in_memory().unwrap();
7162 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7163 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7164 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
7165 image
7166 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
7167 .unwrap();
7168 assert!(matches!(
7169 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
7170 Ok(1)
7171 ));
7172 } else {
7173 let segment = RowsSegment {
7174 table: "tasks".into(),
7175 schema_version: 1,
7176 columns: table.wire_columns.clone(),
7177 blocks: vec![vec![ssp2::segment::SegmentRow {
7178 server_version: 2,
7179 values: values.clone(),
7180 }]],
7181 };
7182 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
7183 }
7184 reference.write_base_row("tasks", &values, 2).unwrap();
7185 reference.rebuild_overlay().expect("rebuild overlay");
7186 for table in ["tasks", "notes"] {
7187 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")] {
7188 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
7189 }
7190 }
7191 assert_eq!(actual.pending_commit_ids(), pending);
7192 }
7193 assert_eq!(
7194 actual.overlay_rebuild_count.get(),
7195 0,
7196 "each chunk must leave unrelated tables alone"
7197 );
7198 let before = actual
7199 .query("SELECT * FROM tasks ORDER BY id", &[])
7200 .unwrap();
7201 let fts_before = actual
7202 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
7203 .unwrap();
7204 let revision = actual.local_revision();
7205 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
7206 let table = actual.schema.table("tasks").unwrap().clone();
7207 let segment = RowsSegment {
7208 table: "tasks".into(),
7209 schema_version: 1,
7210 columns: table.wire_columns.clone(),
7211 blocks: vec![vec![ssp2::segment::SegmentRow {
7212 server_version: 3,
7213 values: vec![
7214 Some(ColumnValue::String("b".into())),
7215 Some(ColumnValue::String("p1".into())),
7216 Some(ColumnValue::String("fresh".into())),
7217 ],
7218 }]],
7219 };
7220 assert!(actual.apply_segment(0, &segment, false).is_err());
7221 assert!(actual.conn.is_autocommit());
7222 assert_eq!(
7223 actual
7224 .query("SELECT * FROM tasks ORDER BY id", &[])
7225 .unwrap(),
7226 before
7227 );
7228 assert_eq!(
7229 actual
7230 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
7231 .unwrap(),
7232 fts_before
7233 );
7234 assert_eq!(actual.local_revision(), revision);
7235 assert_eq!(actual.pending_commit_ids(), pending);
7236 }
7237 }
7238
7239 #[test]
7240 fn import_reconciliation_preserves_typed_pending_primary_keys() {
7241 for (kind, a, b) in [
7242 ("string", json!("a"), json!("b")),
7243 ("integer", json!(1), json!(2)),
7244 ("boolean", json!(true), json!(false)),
7245 ("json", json!("1"), json!("2")),
7246 ] {
7247 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}]}]});
7248 for images in [false, true] {
7249 let mut client =
7250 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
7251 let table = client.schema.table("tasks").unwrap().clone();
7252 client
7253 .subscribe("tasks".into(), "tasks".into(), vec![], None)
7254 .unwrap();
7255 for (id, title) in [(&a, "original"), (&b, "occupied")] {
7256 client
7257 .write_base_row(
7258 "tasks",
7259 &vec![
7260 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
7261 Some(ColumnValue::String(title.into())),
7262 ],
7263 1,
7264 )
7265 .unwrap();
7266 }
7267 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7268 client
7269 .mutate(vec![Mutation::Upsert {
7270 table: "tasks".into(),
7271 values: Map::from_iter([
7272 ("id".into(), a.clone()),
7273 ("title".into(), json!("available")),
7274 ]),
7275 base_version: None,
7276 }])
7277 .unwrap();
7278 client.overlay_rebuild_count.set(0);
7279 for (title, expected) in [("available", "original"), ("occupied", "available")] {
7280 let values = vec![
7281 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
7282 Some(ColumnValue::String(title.into())),
7283 ];
7284 if images {
7285 let image = Connection::open_in_memory().unwrap();
7286 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7287 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7288 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
7289 image
7290 .execute(
7291 "INSERT INTO tasks VALUES (?1,?2,2)",
7292 rusqlite::params![RowParam::Cell(&values[0]), title],
7293 )
7294 .unwrap();
7295 assert!(
7296 matches!(
7297 client.apply_sqlite_image(
7298 &image,
7299 &table,
7300 false,
7301 &[],
7302 1,
7303 7,
7304 "digest"
7305 ),
7306 Ok(1)
7307 ),
7308 "{kind}, images={images}"
7309 );
7310 } else {
7311 let segment = RowsSegment {
7312 table: "tasks".into(),
7313 schema_version: 1,
7314 columns: table.wire_columns.clone(),
7315 blocks: vec![vec![ssp2::segment::SegmentRow {
7316 server_version: 2,
7317 values,
7318 }]],
7319 };
7320 assert!(
7321 matches!(client.apply_segment(0, &segment, false), Ok(1)),
7322 "{kind}"
7323 );
7324 }
7325 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
7326 let actual: String = client
7327 .conn
7328 .query_row(
7329 "SELECT title FROM tasks WHERE id=?1",
7330 [RowParam::Cell(&key)],
7331 |row| row.get(0),
7332 )
7333 .unwrap();
7334 assert_eq!(actual, expected, "{kind}, images={images}");
7335 }
7336 assert_eq!(client.overlay_rebuild_count.get(), 0);
7337 }
7338 }
7339 }
7340
7341 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
7342 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7343 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
7344 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
7345 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7346 })))});
7347 let mut client =
7348 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
7349 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();
7350 client.rebuild_overlay().expect("rebuild overlay");
7351 client
7352 .mutate(vec![Mutation::Upsert {
7353 table: "tasks".into(),
7354 values: Map::from_iter([
7355 ("id".into(), json!("offline")),
7356 ("project_id".into(), json!("p1")),
7357 ("title".into(), json!("pending edit")),
7358 ]),
7359 base_version: None,
7360 }])
7361 .unwrap();
7362 let image = Connection::open_in_memory().unwrap();
7363 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7364 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
7365 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();
7366 image
7367 .execute(
7368 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
7369 [rows],
7370 )
7371 .unwrap();
7372 client.overlay_rebuild_count.set(0);
7373 let table = client.schema.table("tasks").unwrap().clone();
7374 (client, image, table)
7375 }
7376
7377 #[test]
7378 fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
7379 let (mut client, image, table) = unique_import_fixture(1);
7380 image
7381 .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
7382 .unwrap();
7383 client
7384 .conn
7385 .execute_batch("PRAGMA max_page_count = 1")
7386 .unwrap();
7387 let effective = [("project_id".into(), vec!["p1".into()])];
7388 assert!(client
7389 .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
7390 .is_err());
7391 let failure = client.storage_failure.borrow().clone().unwrap();
7392 assert_eq!(failure.code, Some("client.storage_full"));
7393 assert_eq!(failure.sqlite_code, Some(13));
7394 assert!(!failure.message.contains("rollback"));
7395 assert!(client.conn.is_autocommit());
7396 client
7397 .conn
7398 .execute_batch("PRAGMA max_page_count = 1073741823")
7399 .unwrap();
7400 assert!(matches!(
7401 client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
7402 Ok(1)
7403 ));
7404 assert!(client.conn.is_autocommit());
7405 }
7406
7407 #[test]
7408 fn authoring_storage_failures_are_structured_and_scoped() {
7409 let path = std::env::temp_dir().join(format!(
7410 "syncular-authoring-failure-{}.db",
7411 uuid::Uuid::new_v4()
7412 ));
7413 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7414 {"name":"id","type":"string","nullable":false},
7415 {"name":"project_id","type":"string","nullable":false},
7416 {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
7417 let mut client = SyncClient::open_path(
7418 "authoring".into(),
7419 &schema,
7420 ClientLimits::default(),
7421 path.to_str().unwrap(),
7422 )
7423 .expect("open client");
7424 client.create_synced_tables().expect("create synced tables");
7425 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
7427
7428 let upsert = |id: &str| {
7429 vec![Mutation::Upsert {
7430 table: "tasks".into(),
7431 values: Map::from_iter([
7432 ("id".into(), json!(id)),
7433 ("project_id".into(), json!("p1")),
7434 ("title".into(), json!("first")),
7435 ]),
7436 base_version: None,
7437 }]
7438 };
7439
7440 let locker = Connection::open(&path).expect("second connection");
7441 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
7442 let busy = client.mutate(upsert("t1")).expect_err("busy write");
7443 assert_eq!(busy.code, "client.storage_busy");
7444 assert!(busy.retryable);
7445 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
7446 assert!(client.pending_commit_ids().is_empty());
7447 assert_eq!(client.local_revision(), 0);
7448
7449 let invalid = client.mutate(Vec::new()).expect_err("empty commit");
7451 assert_eq!(invalid.code, "sync.invalid_request");
7452 assert_eq!(invalid.message, "the authoring request is invalid");
7453 assert!(
7454 invalid.details.as_ref().unwrap()["legacyCause"]
7455 .as_str()
7456 .unwrap()
7457 .contains("at least one operation"),
7458 "{invalid:?}"
7459 );
7460
7461 locker.execute_batch("ROLLBACK").unwrap();
7462 let id = client.mutate(upsert("t1")).expect("retry after busy");
7463 assert_eq!(client.pending_commit_ids(), vec![id]);
7464 assert_eq!(client.local_revision(), 1);
7465
7466 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
7468 let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
7469 assert_eq!(busy_again.code, "client.storage_busy");
7470 assert!(busy_again.retryable);
7471 locker.execute_batch("ROLLBACK").unwrap();
7472
7473 let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7475 rusqlite::ffi::Error::new(13),
7476 Some("database or disk is full".to_owned()),
7477 )));
7478 assert_eq!(full.code, "client.storage_full");
7479 assert!(!full.retryable);
7480 assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
7481
7482 drop(locker);
7483 drop(client);
7484 let _ = std::fs::remove_file(&path);
7485 }
7486
7487 #[test]
7488 fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
7489 let path = std::env::temp_dir().join(format!(
7490 "syncular-caller-values-{}.db",
7491 uuid::Uuid::new_v4()
7492 ));
7493 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7494 {"name":"id","type":"string","nullable":false},
7495 {"name":"project_id","type":"string","nullable":false},
7496 {"name":"title","type":"string","nullable":false},
7497 {"name":"priority","type":"integer","nullable":false},
7498 {"name":"payload","type":"bytes","nullable":false},
7499 {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
7500 let mut client = SyncClient::open_path(
7501 "caller-values".into(),
7502 &schema,
7503 ClientLimits::default(),
7504 path.to_str().unwrap(),
7505 )
7506 .expect("open client");
7507 client.create_synced_tables().expect("create synced tables");
7508
7509 let values = |id: &str| {
7510 Map::from_iter([
7511 ("id".to_owned(), json!(id)),
7512 ("project_id".to_owned(), json!("p1")),
7513 ("title".to_owned(), json!("first")),
7514 ("priority".to_owned(), json!(1)),
7515 ("payload".to_owned(), json!({"$bytes": "0a"})),
7516 ])
7517 };
7518 let upsert = |values: Map<String, Value>| {
7519 vec![Mutation::Upsert {
7520 table: "tasks".into(),
7521 values,
7522 base_version: None,
7523 }]
7524 };
7525 let seed = client.mutate(upsert(values("t1"))).expect("seed row");
7526 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
7527
7528 for (label, override_value, cause) in [
7533 ("unknown column", ("nope", json!(1)), "unknown column"),
7534 (
7535 "internal sync column",
7536 ("_sync_version", json!(1)),
7537 "internal sync column",
7538 ),
7539 ("wrong value type", ("title", json!(7)), "expected a string"),
7540 (
7541 "malformed bytes envelope",
7542 ("payload", json!({"$bytes": "zz"})),
7543 "expected exactly",
7544 ),
7545 (
7546 "odd-length bytes envelope",
7547 ("payload", json!({"$bytes": "abc"})),
7548 "expected exactly",
7549 ),
7550 (
7551 "plus-sign bytes envelope",
7552 ("payload", json!({"$bytes": "+a"})),
7553 "expected exactly",
7554 ),
7555 (
7556 "bytes envelope with a second key",
7557 ("payload", json!({"$bytes": "0a", "extra": 1})),
7558 "expected exactly",
7559 ),
7560 (
7561 "no bytes envelope",
7562 ("payload", json!({"nested": true})),
7563 "expected exactly",
7564 ),
7565 ] {
7566 let mut full = values("t2");
7567 full.insert(override_value.0.to_owned(), override_value.1);
7568 let error = client.mutate(upsert(full)).expect_err(label);
7569 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
7570 assert_eq!(error.message, "the authoring request is invalid", "{label}");
7571 assert!(!error.retryable, "{label}");
7572 let legacy = error.details.as_ref().unwrap()["legacyCause"]
7573 .as_str()
7574 .unwrap();
7575 assert!(
7576 legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
7577 "{label}: {legacy}"
7578 );
7579 assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
7580 assert_eq!(client.local_revision(), 1, "{label}");
7581 assert_eq!(
7582 client.query("SELECT * FROM tasks", &[]).unwrap(),
7583 rows,
7584 "{label}"
7585 );
7586 }
7587
7588 let mut missing = values("t3");
7591 missing.remove("payload");
7592 let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
7593 assert_eq!(missing_error.code, "sync.invalid_request");
7594 assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
7595 .as_str()
7596 .unwrap()
7597 .contains("is not nullable"));
7598
7599 let mut bad_row_id = values("t4");
7602 bad_row_id.insert("id".to_owned(), json!({"nested": true}));
7603 let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
7604 assert_eq!(row_id_error.code, "sync.invalid_request");
7605
7606 for (label, partial, cause) in [
7608 (
7609 "patch wrong value type",
7610 ("priority", json!("high")),
7611 "expected an integer",
7612 ),
7613 ("patch unknown column", ("nope", json!(1)), "unknown column"),
7614 ] {
7615 let error = client
7616 .patch(
7617 "tasks",
7618 "t1",
7619 Map::from_iter([(partial.0.to_owned(), partial.1)]),
7620 None,
7621 )
7622 .expect_err(label);
7623 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
7624 assert_eq!(error.message, "the authoring request is invalid", "{label}");
7625 assert!(
7626 error.details.as_ref().unwrap()["legacyCause"]
7627 .as_str()
7628 .unwrap()
7629 .contains(cause),
7630 "{label}"
7631 );
7632 }
7633 assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
7634 assert_eq!(client.local_revision(), 1);
7635 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
7636
7637 for (label, payload) in [
7640 ("explicit null", json!(null)),
7641 ("empty envelope", json!({"$bytes": ""})),
7642 ("uppercase envelope", json!({"$bytes": "0AFF"})),
7643 ] {
7644 let mut accepted = values("t-ok");
7645 accepted.insert("extra".to_owned(), payload);
7646 let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
7647 panic!("{label} must be accepted: {error:?}");
7648 });
7649 assert!(!encoded.is_empty(), "{label}");
7650 }
7651
7652 let next = client
7654 .mutate(upsert(values("t5")))
7655 .expect("valid after failures");
7656 assert_ne!(next, seed);
7657
7658 drop(client);
7659 let _ = std::fs::remove_file(&path);
7660 }
7661
7662 #[test]
7663 fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
7664 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7665 {"name":"id","type":"string","nullable":false},
7666 {"name":"project_id","type":"string","nullable":false},
7667 {"name":"payload","type":"bytes","nullable":true}],
7668 "scopes":[{"pattern":"project:{project_id}"}]}]});
7669 let open = |path: &std::path::Path| {
7670 SyncClient::open_path(
7671 "legacy-values".into(),
7672 &schema,
7673 ClientLimits::default(),
7674 path.to_str().unwrap(),
7675 )
7676 .expect("open client")
7677 };
7678 let row = |id: &str| {
7679 Map::from_iter([
7680 ("id".to_owned(), json!(id)),
7681 ("project_id".to_owned(), json!("p1")),
7682 ("payload".to_owned(), json!({"$bytes": "0a"})),
7683 ])
7684 };
7685 let upsert = |values| {
7686 vec![Mutation::Upsert {
7687 table: "tasks".into(),
7688 values,
7689 base_version: None,
7690 }]
7691 };
7692 for (label, stored_cell, representable) in [
7693 ("plus-sign envelope", json!({"$bytes": "+a"}), false),
7694 ("non-hex envelope", json!({"$bytes": "zz"}), false),
7695 ("odd-length envelope", json!({"$bytes": "abc"}), false),
7696 ("no envelope", json!({"nested": true}), false),
7697 ("wrong declared type", json!("high"), true),
7701 ("stored null for a non-nullable column", json!(null), true),
7702 ] {
7703 let path = std::env::temp_dir().join(format!(
7704 "syncular-legacy-values-{}-{}.db",
7705 label.replace(' ', "-"),
7706 uuid::Uuid::new_v4()
7707 ));
7708 let mut client = open(&path);
7709 client.create_synced_tables().expect("create synced tables");
7710 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
7711 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
7712 drop(client);
7713
7714 let connection = Connection::open(&path).expect("raw connection");
7716 let (column, cell) = if stored_cell.is_null() {
7717 ("project_id", stored_cell.clone())
7718 } else {
7719 ("payload", stored_cell.clone())
7720 };
7721 let mut planted_values = json!({
7722 "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
7723 });
7724 planted_values[column] = cell;
7725 let planted = json!([{
7726 "op": "upsert",
7727 "table": "tasks",
7728 "rowId": "t1",
7729 "baseVersion": null,
7730 "values": planted_values
7731 }]);
7732 connection
7733 .execute(
7734 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
7735 rusqlite::params![planted.to_string(), stale],
7736 )
7737 .expect("plant legacy intent");
7738 drop(connection);
7739
7740 let reopened = open(&path);
7743 assert_eq!(
7744 reopened.pending_commit_ids(),
7745 vec![successor.clone()],
7746 "{label}"
7747 );
7748 let rejection = reopened
7749 .rejections()
7750 .iter()
7751 .find(|rejection| rejection.client_commit_id == stale)
7752 .unwrap_or_else(|| panic!("{label}: rejection"));
7753 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
7754 assert_eq!(
7755 rejection.message, "the persisted commit carries values the current codec refuses",
7756 "{label}"
7757 );
7758 assert_eq!(rejection.operation.is_some(), representable, "{label}");
7759 assert_eq!(
7760 rejection
7761 .details
7762 .as_ref()
7763 .and_then(|details| details.reason.as_deref()),
7764 Some(INVALID_STORED_VALUES_REASON),
7765 "{label}"
7766 );
7767 let journal = reopened
7769 .commit_outcome(&stale)
7770 .expect("read outcome")
7771 .expect("journal entry");
7772 assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
7773 drop(reopened);
7774 let mut reopened = open(&path);
7775 let reread = reopened
7776 .commit_outcome(&stale)
7777 .expect("read outcome")
7778 .expect("journal entry");
7779 assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
7780
7781 assert_eq!(
7784 reopened
7785 .query("SELECT id FROM tasks ORDER BY id", &[])
7786 .unwrap(),
7787 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
7788 "{label}"
7789 );
7790 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
7791 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
7792 assert!(!reopened
7793 .drop_unencodable_outbox(false, None)
7794 .expect("idempotent prepass"));
7795 let mut transport = CountingRealtimeTransport::default();
7796 let outcome = reopened.sync(&mut transport);
7797 assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
7798 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
7799 let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
7800 assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
7801 assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
7802 drop(reopened);
7803 let _ = std::fs::remove_file(&path);
7804 }
7805 }
7806
7807 #[test]
7808 fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
7809 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7810 {"name":"id","type":"string","nullable":false},
7811 {"name":"project_id","type":"string","nullable":false},
7812 {"name":"payload","type":"bytes","nullable":true}],
7813 "scopes":[{"pattern":"project:{project_id}"}]}]});
7814 for (label, column, planted) in [
7815 ("malformed envelope", "payload", json!({"$bytes": "zz"})),
7816 (
7817 "stored null for a non-nullable column",
7818 "project_id",
7819 json!(null),
7820 ),
7821 ("wrong declared type", "payload", json!(7)),
7822 ] {
7823 let path = std::env::temp_dir().join(format!(
7824 "syncular-shared-row-{}-{}.db",
7825 label.replace(' ', "-"),
7826 uuid::Uuid::new_v4()
7827 ));
7828 let open = |path: &std::path::Path| {
7829 SyncClient::open_path(
7830 "shared-row".into(),
7831 &schema,
7832 ClientLimits::default(),
7833 path.to_str().unwrap(),
7834 )
7835 .expect("open client")
7836 };
7837 let row = |id: &str| {
7838 Map::from_iter([
7839 ("id".to_owned(), json!(id)),
7840 ("project_id".to_owned(), json!("p1")),
7841 ("payload".to_owned(), json!({"$bytes": "0a"})),
7842 ])
7843 };
7844 let upsert = |values| {
7845 vec![Mutation::Upsert {
7846 table: "tasks".into(),
7847 values,
7848 base_version: None,
7849 }]
7850 };
7851 let mut client = open(&path);
7852 client.create_synced_tables().expect("create synced tables");
7853 let older = client.mutate(upsert(row("t1"))).expect("older commit");
7854 let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
7855 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
7856 drop(client);
7857
7858 let connection = Connection::open(&path).expect("raw connection");
7859 for commit_id in [&older, &newer] {
7860 let mut values = row("t1");
7861 values.insert(column.to_owned(), planted.clone());
7862 let planted_ops = json!([{
7863 "op": "upsert",
7864 "table": "tasks",
7865 "rowId": "t1",
7866 "baseVersion": null,
7867 "values": values
7868 }]);
7869 connection
7870 .execute(
7871 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
7872 rusqlite::params![planted_ops.to_string(), commit_id],
7873 )
7874 .expect("plant legacy intent");
7875 }
7876 drop(connection);
7877
7878 let mut reopened = open(&path);
7880 assert_eq!(
7881 reopened.pending_commit_ids(),
7882 vec![successor.clone()],
7883 "{label}"
7884 );
7885 assert_eq!(
7886 reopened
7887 .rejections()
7888 .iter()
7889 .map(|rejection| rejection.client_commit_id.clone())
7890 .collect::<Vec<_>>(),
7891 vec![older.clone(), newer.clone()],
7892 "{label}"
7893 );
7894 assert!(
7895 reopened
7896 .rejections()
7897 .iter()
7898 .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
7899 && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
7900 == Some(INVALID_STORED_VALUES_REASON)),
7901 "{label}"
7902 );
7903 for commit_id in [&older, &newer] {
7904 assert!(
7905 matches!(
7906 reopened
7907 .commit_outcome(commit_id)
7908 .expect("outcome")
7909 .map(|o| o.status),
7910 Some(CommitOutcomeStatus::Rejected)
7911 ),
7912 "{label}"
7913 );
7914 }
7915 assert_eq!(
7916 reopened
7917 .query("SELECT id FROM tasks ORDER BY id", &[])
7918 .unwrap(),
7919 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
7920 "{label}"
7921 );
7922 let revision = reopened.local_revision();
7923 let batches = reopened.drain_change_batches();
7924 assert!(
7925 batches.iter().any(|batch| batch.rejections_changed
7926 && batch.outcomes_changed
7927 && batch.tables.iter().any(|change| change.table == "tasks")
7928 && batch.revision == revision.to_string()),
7929 "{label}: {batches:?}"
7930 );
7931 drop(reopened);
7932
7933 let mut stable = open(&path);
7936 assert_eq!(stable.local_revision(), revision, "{label}");
7937 assert!(stable.drain_change_batches().is_empty(), "{label}");
7938 assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
7939 drop(stable);
7940 let _ = std::fs::remove_file(&path);
7941 }
7942 }
7943
7944 #[test]
7945 fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
7946 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7947 {"name":"id","type":"string","nullable":false},
7948 {"name":"project_id","type":"string","nullable":false},
7949 {"name":"payload","type":"bytes","nullable":true}],
7950 "scopes":[{"pattern":"project:{project_id}"}]}]});
7951 for (label, second_op) in [
7952 (
7953 "delete carrying values",
7954 json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
7955 ),
7956 (
7957 "baseVersion beyond the safe bound",
7958 json!({"op": "upsert", "table": "tasks", "rowId": "t9",
7959 "baseVersion": MAX_JS_SAFE_INTEGER + 1,
7960 "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
7961 ),
7962 ] {
7963 let path = std::env::temp_dir().join(format!(
7964 "syncular-journal-shape-{}-{}.db",
7965 label.replace(' ', "-"),
7966 uuid::Uuid::new_v4()
7967 ));
7968 let open = |path: &std::path::Path| {
7969 SyncClient::open_path(
7970 "journal-shape".into(),
7971 &schema,
7972 ClientLimits::default(),
7973 path.to_str().unwrap(),
7974 )
7975 .expect("open client")
7976 };
7977 let values = |id: &str| {
7978 Map::from_iter([
7979 ("id".to_owned(), json!(id)),
7980 ("project_id".to_owned(), json!("p1")),
7981 ("payload".to_owned(), json!({"$bytes": "0a"})),
7982 ])
7983 };
7984 let upsert = |values| {
7985 vec![Mutation::Upsert {
7986 table: "tasks".into(),
7987 values,
7988 base_version: None,
7989 }]
7990 };
7991 let mut client = open(&path);
7992 client.create_synced_tables().expect("create synced tables");
7993 let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
7994 drop(client);
7995
7996 let connection = Connection::open(&path).expect("raw connection");
7997 let planted = json!([
8001 {
8002 "op": "upsert",
8003 "table": "tasks",
8004 "rowId": "t1",
8005 "baseVersion": null,
8006 "values": {"id": "t1", "project_id": null,
8007 "payload": {"$bytes": "0a"}}
8008 },
8009 second_op
8010 ]);
8011 connection
8012 .execute(
8013 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8014 rusqlite::params![planted.to_string(), stale],
8015 )
8016 .expect("plant legacy intent");
8017 drop(connection);
8018
8019 let reopened = open(&path);
8020 let journal = reopened
8021 .commit_outcome(&stale)
8022 .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
8023 .unwrap_or_else(|| panic!("{label}: journal entry"));
8024 assert!(
8025 matches!(journal.status, CommitOutcomeStatus::Rejected),
8026 "{label}"
8027 );
8028 assert!(journal.operations.is_none(), "{label}");
8029 assert!(
8030 reopened
8031 .commit_outcomes(CommitOutcomeQuery::default())
8032 .is_ok(),
8033 "{label}: outcome list query"
8034 );
8035 assert!(reopened.pending_commit_ids().is_empty(), "{label}");
8036 drop(reopened);
8037 let _ = std::fs::remove_file(&path);
8038 }
8039 }
8040
8041 #[test]
8042 fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
8043 let schema = |version: i32, extra: bool| {
8044 let mut columns = vec![
8045 json!({"name":"id","type":"string","nullable":false}),
8046 json!({"name":"project_id","type":"string","nullable":false}),
8047 json!({"name":"payload","type":"bytes","nullable":true}),
8048 ];
8049 if extra {
8050 columns.push(json!({"name":"later","type":"string","nullable":true}));
8051 }
8052 json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
8053 "scopes":[{"pattern":"project:{project_id}"}]}]})
8054 };
8055 for (label, bump) in [
8056 ("same-version recreation", false),
8057 ("version-bump reset", true),
8058 ("version-only bump reset", true),
8059 ] {
8060 let path = std::env::temp_dir().join(format!(
8061 "syncular-legacy-reset-{}-{}.db",
8062 label.replace(' ', "-"),
8063 uuid::Uuid::new_v4()
8064 ));
8065 let v1 = schema(1, false);
8066 let open = |schema: &Value, path: &std::path::Path| {
8067 SyncClient::open_path(
8068 "legacy-reset".into(),
8069 schema,
8070 ClientLimits::default(),
8071 path.to_str().unwrap(),
8072 )
8073 .expect("open client")
8074 };
8075 let row = |id: &str| {
8076 Map::from_iter([
8077 ("id".to_owned(), json!(id)),
8078 ("project_id".to_owned(), json!("p1")),
8079 ("payload".to_owned(), json!({"$bytes": "0a"})),
8080 ])
8081 };
8082 let upsert = |values| {
8083 vec![Mutation::Upsert {
8084 table: "tasks".into(),
8085 values,
8086 base_version: None,
8087 }]
8088 };
8089 let mut client = open(&v1, &path);
8090 client.create_synced_tables().expect("create synced tables");
8091 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8092 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8093 drop(client);
8094
8095 let connection = Connection::open(&path).expect("raw connection");
8096 let planted = json!([{
8097 "op": "upsert",
8098 "table": "tasks",
8099 "rowId": "t1",
8100 "baseVersion": null,
8101 "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
8102 }]);
8103 connection
8104 .execute(
8105 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8106 rusqlite::params![planted.to_string(), stale],
8107 )
8108 .expect("plant legacy intent");
8109 drop(connection);
8110
8111 let target = if bump {
8112 schema(2, label == "version-bump reset")
8113 } else {
8114 v1.clone()
8115 };
8116 let reopened = open(&target, &path);
8117 let rejection = reopened
8118 .rejections()
8119 .iter()
8120 .find(|rejection| rejection.client_commit_id == stale)
8121 .unwrap_or_else(|| panic!("{label}: rejection"));
8122 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8123 assert_eq!(
8124 rejection
8125 .details
8126 .as_ref()
8127 .and_then(|details| details.reason.as_deref()),
8128 Some(INVALID_STORED_VALUES_REASON),
8129 "{label}"
8130 );
8131 assert!(rejection.operation.is_some(), "{label}");
8132 assert_eq!(
8133 reopened.pending_commit_ids(),
8134 vec![successor.clone()],
8135 "{label}"
8136 );
8137 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8142 if label == "version-bump reset" {
8143 assert_eq!(
8144 reopened
8145 .query("SELECT id FROM tasks ORDER BY id", &[])
8146 .unwrap(),
8147 Vec::<Map<String, Value>>::new(),
8148 "{label}"
8149 );
8150 let mut next = reopened;
8154 let revision = next.local_revision();
8155 let batches = next.drain_change_batches();
8156 assert!(
8157 batches.iter().any(|batch| batch.rejections_changed
8158 && batch.outcomes_changed
8159 && batch.tables.iter().any(|change| change.table == "tasks")
8160 && batch.revision == revision.to_string()),
8161 "{label}: {batches:?}"
8162 );
8163 next.mutate(upsert(Map::from_iter([
8164 ("id".to_owned(), json!("t3")),
8165 ("project_id".to_owned(), json!("p1")),
8166 ("payload".to_owned(), json!({"$bytes": "0b"})),
8167 ("later".to_owned(), json!("v2")),
8168 ])))
8169 .expect("v2 authoring after the reset");
8170 assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
8171 assert_eq!(next.pending_payloads().len(), 2, "{label}");
8172 drop(next);
8173 } else {
8174 assert_eq!(
8175 reopened
8176 .query("SELECT id FROM tasks ORDER BY id", &[])
8177 .unwrap(),
8178 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8179 "{label}"
8180 );
8181 drop(reopened);
8182 }
8183
8184 if !bump {
8187 let mut recreated = open(&v1, &path);
8188 let fresh = recreated
8189 .mutate(upsert(row("t3")))
8190 .expect("fresh commit while open");
8191 recreated.drain_change_batches();
8192 let planted_fresh = json!([{
8193 "op": "upsert",
8194 "table": "tasks",
8195 "rowId": "t3",
8196 "baseVersion": null,
8197 "values": {"id": "t3", "project_id": "p1",
8198 "payload": {"$bytes": "zz"}}
8199 }]);
8200 let connection = Connection::open(&path).expect("raw connection");
8201 let affected = connection
8202 .execute(
8203 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8204 rusqlite::params![planted_fresh.to_string(), fresh],
8205 )
8206 .expect("plant fresh legacy intent");
8207 assert_eq!(
8208 affected, 1,
8209 "{label}: the in-place plant must hit the queued commit"
8210 );
8211 drop(connection);
8212 let revision_before = recreated.local_revision();
8213 recreated
8214 .recreate_with_schema(&v1)
8215 .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
8216 assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
8217 let fresh_rejection = recreated
8218 .rejections()
8219 .iter()
8220 .find(|rejection| rejection.client_commit_id == fresh)
8221 .expect("the in-place recovery rejection");
8222 assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8223 assert_eq!(
8224 fresh_rejection
8225 .details
8226 .as_ref()
8227 .and_then(|details| details.reason.as_deref()),
8228 Some(INVALID_STORED_VALUES_REASON),
8229 "{label}"
8230 );
8231 assert!(
8232 matches!(
8233 recreated
8234 .commit_outcome(&fresh)
8235 .expect("outcome read")
8236 .map(|outcome| outcome.status),
8237 Some(CommitOutcomeStatus::Rejected)
8238 ),
8239 "{label}"
8240 );
8241 assert!(recreated.local_revision() > revision_before, "{label}");
8242 let revision = recreated.local_revision();
8243 let batches = recreated.drain_change_batches();
8244 assert_eq!(batches.len(), 1, "{label}: one recovery batch");
8245 assert!(
8246 batches.iter().any(|batch| batch.rejections_changed
8247 && batch.outcomes_changed
8248 && batch.tables.iter().any(|change| change.table == "tasks")
8249 && batch.revision == revision.to_string()),
8250 "{label}: {batches:?}"
8251 );
8252 assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
8253 assert_eq!(
8254 recreated
8255 .query("SELECT id FROM tasks ORDER BY id", &[])
8256 .unwrap(),
8257 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8258 "{label}: the recovered commit leaves no projection"
8259 );
8260 drop(recreated);
8261 }
8262 let _ = std::fs::remove_file(&path);
8263 }
8264 }
8265
8266 #[test]
8267 fn legacy_values_recover_over_a_synced_authoritative_row() {
8268 let path = std::env::temp_dir().join(format!(
8269 "syncular-authoritative-row-{}.db",
8270 uuid::Uuid::new_v4()
8271 ));
8272 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8273 {"name":"id","type":"string","nullable":false},
8274 {"name":"project_id","type":"string","nullable":false},
8275 {"name":"title","type":"string","nullable":false},
8276 {"name":"payload","type":"bytes","nullable":true}],
8277 "scopes":[{"pattern":"project:{project_id}"}]}]});
8278 let open = |path: &std::path::Path| {
8279 SyncClient::open_path(
8280 "authoritative-row".into(),
8281 &schema,
8282 ClientLimits::default(),
8283 path.to_str().unwrap(),
8284 )
8285 .expect("open client")
8286 };
8287 let row = |title: &str| {
8288 Map::from_iter([
8289 ("id".to_owned(), json!("t1")),
8290 ("project_id".to_owned(), json!("p1")),
8291 ("title".to_owned(), json!(title)),
8292 ("payload".to_owned(), json!({"$bytes": "0a"})),
8293 ])
8294 };
8295 let upsert = |values| {
8296 vec![Mutation::Upsert {
8297 table: "tasks".into(),
8298 values,
8299 base_version: None,
8300 }]
8301 };
8302 let mut client = open(&path);
8303 client.create_synced_tables().expect("create synced tables");
8304 client
8307 .conn
8308 .execute_batch(
8309 "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
8310 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
8311 )
8312 .expect("seed authoritative row");
8313 let older = client.mutate(upsert(row("older"))).expect("older");
8314 let newer = client.mutate(upsert(row("newer"))).expect("newer");
8315 let successor = client.mutate(upsert(row("successor"))).expect("successor");
8316 drop(client);
8317
8318 let connection = Connection::open(&path).expect("raw connection");
8319 for commit_id in [&older, &newer] {
8320 let planted = json!([{
8321 "op": "upsert",
8322 "table": "tasks",
8323 "rowId": "t1",
8324 "baseVersion": null,
8325 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
8326 "payload": {"$bytes": "zz"}}
8327 }]);
8328 connection
8329 .execute(
8330 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8331 rusqlite::params![planted.to_string(), commit_id],
8332 )
8333 .expect("plant legacy intent");
8334 }
8335 drop(connection);
8336
8337 let reopened = open(&path);
8338 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8339 assert_eq!(
8340 reopened
8341 .rejections()
8342 .iter()
8343 .map(|rejection| rejection.client_commit_id.clone())
8344 .collect::<Vec<_>>(),
8345 vec![older.clone(), newer.clone()]
8346 );
8347 assert_eq!(
8350 reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
8351 Ok(vec![Map::from_iter([
8352 ("id".to_owned(), json!("t1")),
8353 ("title".to_owned(), json!("successor")),
8354 ("_syncular_version".to_owned(), json!(1)),
8355 ])])
8356 );
8357
8358 drop(reopened);
8361 let connection = Connection::open(&path).expect("raw connection");
8362 connection
8363 .execute(
8364 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8365 rusqlite::params![
8366 json!([{
8367 "op": "upsert",
8368 "table": "tasks",
8369 "rowId": "t1",
8370 "baseVersion": null,
8371 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
8372 "payload": {"$bytes": "zz"}}
8373 }])
8374 .to_string(),
8375 successor
8376 ],
8377 )
8378 .expect("plant successor intent");
8379 drop(connection);
8380 let late = open(&path);
8381 assert!(late.pending_commit_ids().is_empty());
8382 assert_eq!(
8383 late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
8384 Ok(vec![Map::from_iter([
8385 ("id".to_owned(), json!("t1")),
8386 ("title".to_owned(), json!("authoritative")),
8387 ("_syncular_version".to_owned(), json!(1)),
8388 ])])
8389 );
8390 drop(late);
8391 let _ = std::fs::remove_file(&path);
8392 }
8393
8394 #[test]
8395 fn a_failed_value_recovery_commit_restores_every_side_effect() {
8396 let path = std::env::temp_dir().join(format!(
8397 "syncular-value-recovery-commit-{}.db",
8398 uuid::Uuid::new_v4()
8399 ));
8400 let path_str = path.to_str().expect("path");
8401 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8402 {"name":"id","type":"string","nullable":false},
8403 {"name":"project_id","type":"string","nullable":false},
8404 {"name":"payload","type":"bytes","nullable":true}],
8405 "scopes":[{"pattern":"project:{project_id}"}]}]});
8406 let conn = Connection::open(path_str).expect("replica connection");
8409 let mut client = SyncClient::with_connection(
8410 "value-recovery-commit".into(),
8411 &schema,
8412 ClientLimits::default(),
8413 conn,
8414 )
8415 .expect("replica");
8416 assert_eq!(
8417 client
8418 .conn
8419 .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
8420 .expect("journal mode"),
8421 "delete"
8422 );
8423 client.create_synced_tables().expect("create synced tables");
8424 let stale = client
8425 .mutate(vec![Mutation::Upsert {
8426 table: "tasks".into(),
8427 values: Map::from_iter([
8428 ("id".to_owned(), json!("t1")),
8429 ("project_id".to_owned(), json!("p1")),
8430 ("payload".to_owned(), json!({"$bytes": "0a"})),
8431 ]),
8432 base_version: None,
8433 }])
8434 .expect("queued write");
8435 client.drain_change_batches();
8436 let affected = client
8437 .conn
8438 .execute(
8439 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8440 rusqlite::params![
8441 json!([{
8442 "op": "upsert",
8443 "table": "tasks",
8444 "rowId": "t1",
8445 "baseVersion": null,
8446 "values": {"id": "t1", "project_id": "p1",
8447 "payload": {"$bytes": "zz"}}
8448 }])
8449 .to_string(),
8450 stale
8451 ],
8452 )
8453 .expect("plant legacy intent");
8454 assert_eq!(affected, 1, "the planted row must exist");
8455 let revision = client.local_revision();
8456 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
8457 let prior_outbox = client.pending_commit_ids();
8458 let prior_rejections = client.rejections().len();
8459 let prior_changes = client.change_queue.len();
8460 let prior_intents = client.sync_intent_queue.len();
8461 let prior_failed = client.failed_commits.len();
8462 let reader = Connection::open(path_str).expect("reader");
8465 reader
8466 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
8467 .expect("reader snapshot");
8468 client
8469 .conn
8470 .busy_timeout(std::time::Duration::ZERO)
8471 .expect("busy timeout");
8472 let error = client
8473 .recreate_with_schema(&schema)
8474 .expect_err("a blocked COMMIT must refuse the recovery");
8475 assert!(error.starts_with("client.storage_busy:"), "{error}");
8476 assert!(client.conn.is_autocommit(), "the transaction is released");
8477 assert_eq!(client.local_revision(), revision);
8478 assert_eq!(client.pending_commit_ids(), prior_outbox);
8479 assert_eq!(client.rejections().len(), prior_rejections);
8480 assert_eq!(client.change_queue.len(), prior_changes);
8481 assert_eq!(client.sync_intent_queue.len(), prior_intents);
8482 assert_eq!(client.failed_commits.len(), prior_failed);
8483 assert_eq!(
8484 client.query("SELECT * FROM tasks", &[]).expect("rows"),
8485 rows
8486 );
8487 assert!(
8488 client
8489 .commit_outcome(&stale)
8490 .expect("outcome read")
8491 .is_none(),
8492 "the rolled-back recovery kept no outcome"
8493 );
8494 reader.execute_batch("ROLLBACK").expect("release reader");
8496 client
8497 .recreate_with_schema(&schema)
8498 .expect("retry after the blocked commit");
8499 assert!(client.pending_commit_ids().is_empty());
8500 assert_eq!(client.rejections().len(), prior_rejections + 1);
8501 assert!(client.local_revision() > revision);
8502 let batches = client.drain_change_batches();
8503 assert!(
8504 batches.iter().any(|batch| batch.rejections_changed
8505 && batch.outcomes_changed
8506 && batch.tables.iter().any(|change| change.table == "tasks")),
8507 "{batches:?}"
8508 );
8509 drop(client);
8510 let _ = std::fs::remove_file(&path);
8511 }
8512
8513 #[cfg(feature = "e2ee")]
8514 #[test]
8515 fn a_failed_recovery_restores_retained_failed_commits() {
8516 let path = std::env::temp_dir().join(format!(
8517 "syncular-value-recovery-retention-{}.db",
8518 uuid::Uuid::new_v4()
8519 ));
8520 let path_str = path.to_str().expect("path");
8521 let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
8522 {"name":"id","type":"string","nullable":false},
8523 {"name":"project_id","type":"string","nullable":false},
8524 {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
8525 "scopes":[{"pattern":"project:{project_id}"}]}]});
8526 let conn = Connection::open(path_str).expect("replica connection");
8527 let mut client = SyncClient::with_connection(
8528 "value-recovery-retention".into(),
8529 &schema,
8530 ClientLimits::default(),
8531 conn,
8532 )
8533 .expect("replica");
8534 client.create_synced_tables().expect("create synced tables");
8535 client.set_retain_failed_commits(true).expect("retention");
8536 client.set_encryption(crate::values::EncryptionConfig::default());
8538 client
8539 .mutate(vec![Mutation::Upsert {
8540 table: "secrets".into(),
8541 values: Map::from_iter([
8542 ("id".to_owned(), json!("s1")),
8543 ("project_id".to_owned(), json!("p1")),
8544 ("note".to_owned(), json!("secret")),
8545 ]),
8546 base_version: None,
8547 }])
8548 .expect("queued write");
8549 client.drain_change_batches();
8550 let revision = client.local_revision();
8551 let prior_outbox = client.pending_commit_ids();
8552 let prior_rejections = client.rejections().len();
8553 let prior_failed = client.failed_commits.len();
8554 let reader = Connection::open(path_str).expect("reader");
8555 reader
8556 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
8557 .expect("reader snapshot");
8558 client
8559 .conn
8560 .busy_timeout(std::time::Duration::ZERO)
8561 .expect("busy timeout");
8562 let error = client
8563 .drop_unencodable_outbox(true, None)
8564 .expect_err("a blocked COMMIT must refuse the recovery");
8565 assert!(error.starts_with("client.storage_busy:"), "{error}");
8566 assert_eq!(client.failed_commits.len(), prior_failed);
8567 assert_eq!(client.pending_commit_ids(), prior_outbox);
8568 assert_eq!(client.rejections().len(), prior_rejections);
8569 assert_eq!(client.local_revision(), revision);
8570 reader.execute_batch("ROLLBACK").expect("release reader");
8572 client
8573 .drop_unencodable_outbox(true, None)
8574 .expect("retry after the blocked commit");
8575 assert_eq!(client.failed_commits.len(), prior_failed + 1);
8576 assert!(client.pending_commit_ids().is_empty());
8577 assert_eq!(client.rejections().len(), prior_rejections + 1);
8578 drop(client);
8579 let _ = std::fs::remove_file(&path);
8580 }
8581
8582 #[test]
8583 fn client_error_maps_legacy_codes_and_static_messages() {
8584 let code_only = ClientError::from("sync.unknown_table".to_owned());
8585 assert_eq!(code_only.code, "sync.unknown_table");
8586 assert_eq!(code_only.message, "the commit targets an unknown table");
8587 assert!(code_only.details.is_none());
8588
8589 let dynamic = ClientError::from(
8590 "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
8591 );
8592 assert_eq!(dynamic.code, "sync.invalid_request");
8593 assert_eq!(dynamic.message, "the authoring request is invalid");
8594 assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
8595 .as_str()
8596 .unwrap()
8597 .contains("patch cannot write scope column"));
8598
8599 let unclassified =
8600 ClientError::from("a commit must contain at least one operation".to_owned());
8601 assert_eq!(unclassified.code, "client.failed");
8602 assert_eq!(unclassified.message, "the local authoring operation failed");
8603 assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
8604 .as_str()
8605 .unwrap()
8606 .contains("at least one operation"));
8607
8608 let metadata = ClientError::from(QueryReadFailure {
8610 code: None,
8611 sqlite_code: Some(1),
8612 sqlite_message: Some("generic SQLite prose".to_owned()),
8613 rollback_failure: Some(Box::new(QueryReadFailure::from(
8614 rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
8615 ))),
8616 message: "unclassified read".to_owned(),
8617 });
8618 assert_eq!(metadata.code, "client.failed");
8619 assert_eq!(metadata.message, "the local authoring operation failed");
8620 let details = metadata.details.as_ref().unwrap();
8621 assert_eq!(details["sqliteCode"], 1);
8622 assert_eq!(details["sqliteMessage"], "generic SQLite prose");
8623 assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
8624 assert_eq!(details["legacyCause"], "unclassified read");
8625 }
8626
8627 #[test]
8628 fn schema_marker_read_keeps_storage_classification() {
8629 let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
8630 rusqlite::ffi::Error::new(5),
8631 Some("database is locked".to_owned()),
8632 ));
8633 assert!(busy.starts_with("client.storage_busy:"), "{busy}");
8634 let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
8635 rusqlite::ffi::Error::new(1),
8636 Some("unreadable".to_owned()),
8637 ));
8638 assert_eq!(
8639 invalid,
8640 "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
8641 );
8642
8643 let path =
8646 std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
8647 let setup = Connection::open(&path).expect("setup connection");
8648 setup
8649 .execute_batch(
8650 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
8651 )
8652 .unwrap();
8653 let locker = Connection::open(&path).expect("locker connection");
8654 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
8655 let reader = Connection::open(&path).expect("reader connection");
8656 reader.busy_timeout(std::time::Duration::ZERO).unwrap();
8657 let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
8658 assert!(locked.starts_with("client.storage_busy:"), "{locked}");
8659 locker.execute_batch("ROLLBACK").unwrap();
8660 assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
8661 let rows = setup
8662 .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
8663 row.get::<_, i64>(0)
8664 })
8665 .unwrap();
8666 assert_eq!(rows, 1, "the guard performed no writes");
8667 drop(reader);
8668 drop(locker);
8669 drop(setup);
8670 let _ = std::fs::remove_file(&path);
8671 }
8672
8673 #[cfg(feature = "crdt-yjs")]
8674 #[test]
8675 fn crdt_authoring_pre_read_preserves_storage_classification() {
8676 let path =
8677 std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
8678 let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
8679 {"name":"id","type":"string","nullable":false},
8680 {"name":"project_id","type":"string","nullable":false},
8681 {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
8682 "scopes":[{"pattern":"project:{project_id}"}]}]});
8683 let mut client = SyncClient::open_path(
8684 "crdt-busy".into(),
8685 &schema,
8686 ClientLimits::default(),
8687 path.to_str().unwrap(),
8688 )
8689 .expect("open client");
8690 client.create_synced_tables().expect("create synced tables");
8691 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8692
8693 let locker = Connection::open(&path).expect("locker connection");
8694 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
8695 let busy = client
8696 .crdt_apply_update("notes", "n1", "doc", &[])
8697 .expect_err("busy crdt pre-read");
8698 assert_eq!(busy.code, "client.storage_busy");
8699 assert!(busy.retryable);
8700 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8701 assert!(client.pending_commit_ids().is_empty());
8702 assert_eq!(client.local_revision(), 0);
8703
8704 locker.execute_batch("ROLLBACK").unwrap();
8705 drop(locker);
8706 drop(client);
8707 let _ = std::fs::remove_file(&path);
8708 }
8709
8710 #[test]
8711 fn large_unique_import_copies_no_unrelated_tables() {
8712 let (mut client, image, table) = unique_import_fixture(10240);
8713 let pending = client.pending_commit_ids();
8714 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
8715 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
8716 assert!(matches!(
8717 client.apply_sqlite_image(
8718 &image,
8719 &table,
8720 true,
8721 &[("project_id".into(), vec!["p1".into()])],
8722 10240,
8723 7,
8724 "digest"
8725 ),
8726 Ok(10240)
8727 ));
8728 assert_eq!(client.overlay_rebuild_count.get(), 0);
8729 assert_eq!(client.pending_commit_ids(), pending);
8730 assert_eq!(
8731 client
8732 .query("SELECT count(*) AS n FROM tasks", &[])
8733 .unwrap()[0]["n"],
8734 10241
8735 );
8736 assert_eq!(
8737 client
8738 .query("SELECT count(*) AS n FROM tasks_fts", &[])
8739 .unwrap()[0]["n"],
8740 10241
8741 );
8742 assert_eq!(
8743 client
8744 .query("SELECT count(*) AS n FROM notes_fts", &[])
8745 .unwrap()[0]["n"],
8746 8192
8747 );
8748 }
8749
8750 #[test]
8751 #[ignore = "manual scaling benchmark; see bench/README.md"]
8752 fn benchmark_unique_image_import() {
8753 let mut samples = Vec::new();
8754 for rows in [10240, 51200, 102400] {
8755 for trial in 0..4 {
8756 let (mut client, image, table) = unique_import_fixture(rows);
8757 let start = std::time::Instant::now();
8758 assert!(
8759 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)
8760 );
8761 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
8762 assert_eq!(
8763 client
8764 .query("SELECT count(*) AS n FROM tasks", &[])
8765 .unwrap()[0]["n"],
8766 rows + 1
8767 );
8768 assert_eq!(
8769 client
8770 .query("SELECT count(*) AS n FROM tasks_fts", &[])
8771 .unwrap()[0]["n"],
8772 rows + 1
8773 );
8774 assert_eq!(
8775 client
8776 .query("SELECT count(*) AS n FROM notes_fts", &[])
8777 .unwrap()[0]["n"],
8778 8192
8779 );
8780 assert_eq!(client.pending_commit_ids().len(), 1);
8781 if trial > 0 {
8782 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
8783 }
8784 }
8785 }
8786 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});
8787 let output = serde_json::to_string_pretty(&artifact).unwrap();
8788 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
8789 use std::io::Write;
8790 let mut file = std::fs::OpenOptions::new()
8791 .write(true)
8792 .create_new(true)
8793 .open(path)
8794 .unwrap();
8795 file.write_all(output.as_bytes()).unwrap();
8796 } else {
8797 println!("{output}");
8798 }
8799 }
8800
8801 #[test]
8802 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
8803 let schema = json!({
8804 "version": 1,
8805 "tables": [{
8806 "name": "catalogue_codes",
8807 "primaryKey": "id",
8808 "columns": [
8809 { "name": "id", "type": "string", "nullable": false },
8810 { "name": "release_id", "type": "string", "nullable": false },
8811 { "name": "code", "type": "string", "nullable": false },
8812 { "name": "title", "type": "string", "nullable": false }
8813 ],
8814 "scopes": [{ "pattern": "release:{release_id}" }],
8815 "ftsIndexes": [{
8816 "name": "catalogue_codes_fts",
8817 "columns": ["code", "title"],
8818 "tokenize": "unicode61 remove_diacritics 2"
8819 }]
8820 }]
8821 });
8822 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
8823 .expect("FTS5 client");
8824 let insert_trigger: String = client
8825 .conn
8826 .query_row(
8827 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
8828 [],
8829 |row| row.get(0),
8830 )
8831 .expect("insert trigger");
8832 let replace_guard: String = client
8833 .conn
8834 .query_row(
8835 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
8836 [],
8837 |row| row.get(0),
8838 )
8839 .expect("replace guard");
8840 assert!(!insert_trigger.contains("DELETE FROM"));
8841 assert!(replace_guard.contains("BEFORE INSERT"));
8842 assert!(replace_guard.contains("WHEN EXISTS"));
8843 let search = |client: &SyncClient, query: &str| {
8844 client
8845 .query(
8846 "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",
8847 &[Value::from(query)],
8848 )
8849 .expect("FTS query")
8850 };
8851
8852 client
8853 .mutate(vec![Mutation::Upsert {
8854 table: "catalogue_codes".to_owned(),
8855 values: Map::from_iter([
8856 ("id".to_owned(), Value::from("c1")),
8857 ("release_id".to_owned(), Value::from("r1")),
8858 ("code".to_owned(), Value::from("A01")),
8859 ("title".to_owned(), Value::from("Cholera")),
8860 ]),
8861 base_version: None,
8862 }])
8863 .expect("insert code");
8864 assert_eq!(search(&client, "cholera").len(), 1);
8865 let prior = client
8866 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
8867 .unwrap();
8868 client
8869 .conn
8870 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
8871 .unwrap();
8872 client.create_synced_tables().unwrap();
8873 assert_eq!(
8874 client
8875 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
8876 .unwrap(),
8877 prior
8878 );
8879 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();
8880 let rendered = serde_json::to_string(&plan).unwrap();
8881 assert!(rendered.contains("COVERING INDEX"));
8882 assert!(rendered.contains("INDEX 0:="));
8883 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
8884 assert_eq!(search(&client, "cholera").len(), 1);
8885
8886 client
8887 .mutate(vec![Mutation::Upsert {
8888 table: "catalogue_codes".to_owned(),
8889 values: Map::from_iter([
8890 ("id".to_owned(), Value::from("c1")),
8891 ("release_id".to_owned(), Value::from("r1")),
8892 ("code".to_owned(), Value::from("A01")),
8893 ("title".to_owned(), Value::from("Enteric infection")),
8894 ]),
8895 base_version: None,
8896 }])
8897 .expect("update code");
8898 assert!(search(&client, "cholera").is_empty());
8899 assert_eq!(search(&client, "enteric").len(), 1);
8900
8901 client
8902 .mutate(vec![Mutation::Delete {
8903 table: "catalogue_codes".to_owned(),
8904 row_id: "c1".to_owned(),
8905 base_version: None,
8906 }])
8907 .expect("delete code");
8908 assert!(search(&client, "enteric").is_empty());
8909 }
8910
8911 #[test]
8912 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
8913 let schema = json!({
8914 "version": 1,
8915 "tables": [{
8916 "name": "patient_notes",
8917 "primaryKey": "id",
8918 "columns": [
8919 { "name": "id", "type": "string", "nullable": false },
8920 { "name": "practice_id", "type": "string", "nullable": false },
8921 { "name": "encryption_key_id", "type": "string", "nullable": false },
8922 { "name": "title", "type": "string", "nullable": false }
8923 ],
8924 "scopes": [{ "pattern": "practice:{practice_id}" }],
8925 "ftsIndexes": [{
8926 "name": "patient_notes_fts",
8927 "columns": ["title"],
8928 "tokenize": "unicode61 remove_diacritics 2"
8929 }]
8930 }]
8931 });
8932 let mut client = SyncClient::new(
8933 "local-purge-test".to_owned(),
8934 &schema,
8935 ClientLimits::default(),
8936 )
8937 .expect("local purge client");
8938 client
8939 .conn
8940 .execute(
8941 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
8942 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
8943 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
8944 [],
8945 )
8946 .expect("seed base rows");
8947 client.overlay_dirty.set(true);
8948 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8949
8950 let note = |id: &str, key_id: &str, title: &str| {
8951 Map::from_iter([
8952 ("id".to_owned(), Value::from(id)),
8953 ("practice_id".to_owned(), Value::from("practice-1")),
8954 ("encryption_key_id".to_owned(), Value::from(key_id)),
8955 ("title".to_owned(), Value::from(title)),
8956 ])
8957 };
8958 let doomed = client
8959 .mutate(vec![
8960 Mutation::Upsert {
8961 table: "patient_notes".to_owned(),
8962 values: note("target", "key-revoked", "Target changed"),
8963 base_version: None,
8964 },
8965 Mutation::Upsert {
8966 table: "patient_notes".to_owned(),
8967 values: note("unrelated", "key-held", "Unrelated changed"),
8968 base_version: None,
8969 },
8970 ])
8971 .expect("doomed commit");
8972 let kept = client
8973 .mutate(vec![Mutation::Upsert {
8974 table: "patient_notes".to_owned(),
8975 values: note("kept", "key-held", "Kept optimistic"),
8976 base_version: None,
8977 }])
8978 .expect("kept commit");
8979 client.drain_change_batches();
8980
8981 let input = LocalDataPurgeInput {
8982 purge_id: "purge-001".to_owned(),
8983 targets: vec![LocalDataPurgeTarget {
8984 table: "patient_notes".to_owned(),
8985 selectors: BTreeMap::from([(
8986 "encryption_key_id".to_owned(),
8987 vec!["key-revoked".to_owned()],
8988 )]),
8989 }],
8990 };
8991 assert_eq!(
8992 client.purge_local_data(&input).expect("apply purge"),
8993 LocalDataPurgeResult {
8994 already_applied: false,
8995 purged_rows: 1,
8996 dropped_commits: 1,
8997 }
8998 );
8999 let rows = client
9000 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
9001 .expect("visible rows");
9002 assert_eq!(rows.len(), 2);
9003 assert_eq!(rows[0]["id"], "kept");
9004 assert_eq!(rows[1]["id"], "unrelated");
9005 assert_eq!(rows[1]["title"], "Unrelated original");
9006 let fts = client
9007 .query(
9008 "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'",
9009 &[],
9010 )
9011 .expect("fts query");
9012 assert!(fts.is_empty());
9013 assert_eq!(client.outbox.len(), 1);
9014 assert_eq!(client.outbox[0].client_commit_id, kept);
9015 let outcome = client
9016 .commit_outcome(&doomed)
9017 .expect("read doomed outcome")
9018 .expect("doomed outcome");
9019 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
9020 match &outcome.results[0] {
9021 CommitOperationOutcome::Error { rejection } => {
9022 assert_eq!(rejection.code, "client.local_data_purged");
9023 assert_eq!(rejection.client_commit_id, doomed);
9024 }
9025 other => panic!("expected local purge rejection, got {other:?}"),
9026 }
9027 assert_eq!(client.drain_change_batches().len(), 1);
9028 assert_eq!(
9029 client.purge_local_data(&input).expect("retry purge"),
9030 LocalDataPurgeResult {
9031 already_applied: true,
9032 purged_rows: 0,
9033 dropped_commits: 0,
9034 }
9035 );
9036 let conflicting = LocalDataPurgeInput {
9037 purge_id: input.purge_id.clone(),
9038 targets: vec![LocalDataPurgeTarget {
9039 table: "patient_notes".to_owned(),
9040 selectors: BTreeMap::from([(
9041 "encryption_key_id".to_owned(),
9042 vec!["key-held".to_owned()],
9043 )]),
9044 }],
9045 };
9046 assert!(client
9047 .purge_local_data(&conflicting)
9048 .expect_err("id collision must fail")
9049 .contains("already used with a different plan"));
9050 }
9051}
9052
9053impl SubState {
9054 fn name(self) -> &'static str {
9055 match self {
9056 SubState::Active => "active",
9057 SubState::Revoked => "revoked",
9058 SubState::Failed => "failed",
9059 }
9060 }
9061
9062 fn parse(value: &str) -> Self {
9063 match value {
9064 "revoked" => Self::Revoked,
9065 "failed" => Self::Failed,
9066 _ => Self::Active,
9067 }
9068 }
9069}
9070
9071#[derive(Debug, Clone)]
9072struct Subscription {
9073 id: String,
9074 table: String,
9075 requested: Vec<(String, Vec<String>)>,
9076 params: Option<String>,
9077 cursor: i64,
9078 bootstrap_state: Option<String>,
9080 state: SubState,
9081 reason_code: Option<String>,
9082 effective: Option<Vec<(String, Vec<String>)>>,
9085 synced_once: bool,
9086}
9087
9088#[derive(Debug, Clone)]
9089struct OutboxOp {
9090 upsert: bool,
9091 table: String,
9092 row_id: String,
9093 base_version: Option<i64>,
9094 values: Option<Map<String, Value>>,
9098}
9099
9100impl From<&OutboxOp> for CommitOperation {
9101 fn from(operation: &OutboxOp) -> Self {
9102 Self {
9103 table: operation.table.clone(),
9104 row_id: operation.row_id.clone(),
9105 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
9106 base_version: operation.base_version,
9107 values: operation.values.clone(),
9108 }
9109 }
9110}
9111
9112#[derive(Debug, Clone)]
9113struct OutboxCommit {
9114 client_commit_id: String,
9115 ops: Vec<OutboxOp>,
9116}
9117
9118fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
9122 commit
9123 .ops
9124 .iter()
9125 .filter(|operation| operation.upsert)
9126 .map(|operation| {
9127 (
9128 operation.table.as_str(),
9129 operation
9130 .values
9131 .as_ref()
9132 .map(|values| values.keys().map(String::as_str).collect())
9133 .unwrap_or_default(),
9134 )
9135 })
9136 .collect()
9137}
9138
9139type FailedCommit = (OutboxCommit, Map<String, Value>);
9140
9141#[derive(Debug, Clone)]
9142struct CompiledLocalDataPurgeTarget {
9143 table: String,
9144 selectors: Vec<(String, Vec<String>)>,
9145}
9146
9147struct StoredCommitOutcomeRow {
9148 sequence: i64,
9149 client_commit_id: String,
9150 status: String,
9151 recorded_at_ms: i64,
9152 results_json: String,
9153 operations_json: Option<String>,
9154 resolution: String,
9155 resolved_at_ms: Option<i64>,
9156 replacement_client_commit_id: Option<String>,
9157}
9158
9159impl StoredCommitOutcomeRow {
9160 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
9161 Ok(Self {
9162 sequence: row.get(0)?,
9163 client_commit_id: row.get(1)?,
9164 status: row.get(2)?,
9165 recorded_at_ms: row.get(3)?,
9166 results_json: row.get(4)?,
9167 operations_json: row.get(5)?,
9168 resolution: row.get(6)?,
9169 resolved_at_ms: row.get(7)?,
9170 replacement_client_commit_id: row.get(8)?,
9171 })
9172 }
9173}
9174
9175enum SectionError {
9178 FailClosed,
9179 Abort(String, String),
9180}
9181
9182#[derive(Debug)]
9183pub(crate) struct RequestMeta {
9184 pushed_ids: Vec<String>,
9185 fresh: Vec<(String, bool)>,
9188 pub(crate) accept: u8,
9189 deferred_commits: usize,
9193}
9194
9195#[derive(Default)]
9196struct ChangeAccumulator {
9197 tables: BTreeMap<String, Option<BTreeSet<String>>>,
9199 windows: BTreeMap<(String, String), BTreeSet<String>>,
9200 status: bool,
9201 conflicts: bool,
9202 rejections: bool,
9203 outcomes: bool,
9204}
9205
9206impl ChangeAccumulator {
9207 fn table(&mut self, table: &str) {
9208 self.tables.insert(table.to_owned(), None);
9209 }
9210
9211 fn scope(&mut self, table: &str, key: String) {
9212 match self.tables.get_mut(table) {
9213 Some(None) => {}
9214 Some(Some(keys)) => {
9215 keys.insert(key);
9216 }
9217 None => {
9218 self.tables
9219 .insert(table.to_owned(), Some(BTreeSet::from([key])));
9220 }
9221 }
9222 }
9223
9224 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
9225 self.windows
9226 .entry((base_key.to_owned(), table.to_owned()))
9227 .or_default()
9228 .insert(unit.to_owned());
9229 }
9230
9231 fn touched(&self) -> bool {
9232 !self.tables.is_empty()
9233 || !self.windows.is_empty()
9234 || self.status
9235 || self.conflicts
9236 || self.rejections
9237 || self.outcomes
9238 }
9239}
9240
9241const PUSH_OPS_PER_REQUEST: usize = 500;
9247const MAX_LOCAL_PURGE_TARGETS: usize = 64;
9248const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
9249const MAX_LOCAL_PURGE_VALUES: usize = 128;
9250const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
9251pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
9252
9253struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
9255
9256impl OverlayDirty {
9257 fn clean() -> Self {
9258 Self(RefCell::new(Some(BTreeSet::new())))
9259 }
9260 fn get(&self) -> bool {
9261 self.0
9262 .borrow()
9263 .as_ref()
9264 .is_none_or(|tables| !tables.is_empty())
9265 }
9266 fn set(&self, dirty: bool) {
9267 *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
9268 }
9269 fn table(&self, table: &str) {
9270 if let Some(tables) = self.0.borrow_mut().as_mut() {
9271 tables.insert(table.to_owned());
9272 }
9273 }
9274 fn snapshot(&self) -> Option<BTreeSet<String>> {
9275 self.0.borrow().clone()
9276 }
9277 fn restore(&self, tables: Option<BTreeSet<String>>) {
9278 *self.0.borrow_mut() = tables;
9279 }
9280}
9281
9282pub struct SyncClient {
9283 #[cfg(feature = "bench-internals")]
9284 benchmark_phases: Recorder,
9285 conn: Connection,
9286 schema: ClientSchema,
9287 client_id: String,
9288 limits: ClientLimits,
9289 subs: Vec<Subscription>,
9290 outbox: Vec<OutboxCommit>,
9291 failed_commits: Vec<FailedCommit>,
9292 retain_failed_commits: bool,
9293 conflicts: Vec<ConflictRecord>,
9294 rejections: Vec<RejectionRecord>,
9295 schema_floor: Option<SchemaFloor>,
9296 lease_state: Option<LeaseState>,
9298 stopped: bool,
9300 upgrading: bool,
9302 sync_needed: bool,
9304 realtime_policy: RealtimePolicy,
9307 realtime_state: RealtimeState,
9308 realtime_reason_code: Option<String>,
9309 realtime_retry_delay_ms: Option<u64>,
9310 presence: HashMap<String, HashMap<String, PresencePeer>>,
9312 now_ms: Option<i64>,
9315 encryption: crate::values::EncryptionConfig,
9319 security_preflight: bool,
9322 transport_enabled: bool,
9323 previous_version: Option<PreviousVersionContextConfig>,
9327 insert_sql: RefCell<HashMap<String, String>>,
9333 overlay_dirty: OverlayDirty,
9336 storage_failure: RefCell<Option<QueryReadFailure>>,
9338 #[cfg(test)]
9341 overlay_rebuild_count: Cell<usize>,
9342 #[cfg(test)]
9343 acknowledged_replay_count: Cell<usize>,
9344 #[cfg(test)]
9347 outcome_prune_count: Cell<usize>,
9348 progress: ProgressObserver,
9350 change_queue: VecDeque<ClientChangeBatch>,
9351 sync_intent_queue: VecDeque<SyncIntent>,
9352 retry_delay_ms: u64,
9354 round_retry_delay_ms: Option<u64>,
9356 active_round: Option<uuid::Uuid>,
9357 last_round: Option<DiagnosticLastRound>,
9358 last_change: Option<DiagnosticLastChange>,
9359 query_failures: Vec<DiagnosticQueryFailure>,
9361}
9362
9363pub(crate) fn quote_ident(name: &str) -> String {
9364 format!("\"{}\"", name.replace('"', "\"\""))
9365}
9366pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
9370 conn.query_row(
9371 "SELECT value FROM _syncular_meta WHERE key = ?1",
9372 rusqlite::params![key],
9373 |row| row.get::<_, String>(0),
9374 )
9375 .ok()
9376}
9377
9378pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
9379 let _ = conn.execute(
9380 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
9381 rusqlite::params![key, value],
9382 );
9383}
9384
9385pub(crate) fn meta_delete(conn: &Connection, key: &str) {
9386 let _ = conn.execute(
9387 "DELETE FROM _syncular_meta WHERE key = ?1",
9388 rusqlite::params![key],
9389 );
9390}
9391
9392fn row_id_predicate(table: &TableSchema) -> String {
9394 let key = quote_ident(&table.primary_key);
9395 let text_match = format!("CAST({key} AS TEXT) = ?1");
9396 match table.columns[table.pk_index].ty {
9397 ColumnType::String | ColumnType::Json => {
9398 format!("{key} = ?1 AND {text_match}")
9399 }
9400 ColumnType::Integer | ColumnType::Boolean => {
9404 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
9405 }
9406 _ => text_match,
9409 }
9410}
9411
9412fn is_local_operation_code_like(value: &str) -> bool {
9413 let bytes = value.as_bytes();
9414 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
9415 && bytes
9416 .iter()
9417 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
9418}
9419
9420fn base_table(name: &str) -> String {
9421 quote_ident(&format!("_syncular_base_{name}"))
9422}
9423
9424type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
9429
9430fn window_base_key(base: &WindowBase) -> String {
9431 format!(
9432 "{}\0{}\0{}",
9433 base.table,
9434 base.variable,
9435 canonical_scope_json(&base.fixed_scopes)
9436 )
9437}
9438
9439fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
9441 let mut scopes = base.fixed_scopes.clone();
9442 scopes.retain(|(k, _)| k != &base.variable);
9443 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
9444 scopes
9445}
9446
9447fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
9451 let canonical = canonical_scope_json(&unit_scopes(base, unit));
9452 let digest = Sha256::digest(canonical.as_bytes());
9453 let hex = bytes_to_hex(&digest);
9454 format!("w:{}:{}", base.table, &hex[..16])
9455}
9456
9457fn visible_table(name: &str) -> String {
9458 quote_ident(name)
9459}
9460
9461const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
9462
9463fn is_synced_table_name(name: &str) -> bool {
9466 if name.starts_with("sqlite_") {
9467 return false;
9468 }
9469 if name.starts_with("_syncular_base_") {
9470 return true; }
9472 !name.starts_with("_syncular_")
9474}
9475
9476fn blob_id_for(bytes: &[u8]) -> String {
9478 let digest = Sha256::digest(bytes);
9479 format!("sha256:{}", bytes_to_hex(&digest))
9480}
9481
9482enum RowParam<'a> {
9486 Cell(&'a Option<ColumnValue>),
9487 Version(i64),
9488}
9489
9490impl rusqlite::ToSql for RowParam<'_> {
9491 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
9492 Ok(match self {
9493 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
9494 RowParam::Cell(cell) => match cell {
9495 None => ToSqlOutput::Owned(SqlValue::Null),
9496 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
9497 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
9498 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
9499 Some(ColumnValue::Boolean(b)) => {
9500 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
9501 }
9502 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
9503 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
9504 }
9505 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
9507 ToSqlOutput::Borrowed(ValueRef::Blob(b))
9508 }
9509 },
9510 })
9511 }
9512}
9513
9514fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
9516 match param.to_sql().map_err(|error| error.to_string())? {
9517 ToSqlOutput::Owned(value) => Ok(value),
9518 ToSqlOutput::Borrowed(value) => {
9519 SqlValue::try_from(value).map_err(|error| error.to_string())
9520 }
9521 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
9522 }
9523}
9524
9525fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
9533 use ssp2::segment::ColumnType;
9534 let mismatch = || {
9535 Err(format!(
9536 "image column {:?} holds a value of the wrong type",
9537 column.name
9538 ))
9539 };
9540 match value {
9541 ValueRef::Null => {
9542 if !column.nullable {
9543 return Err(format!(
9544 "image column {:?} is NULL but not nullable",
9545 column.name
9546 ));
9547 }
9548 Ok(ToSqlOutput::Owned(SqlValue::Null))
9549 }
9550 ValueRef::Integer(i) => match column.ty {
9551 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
9552 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
9553 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
9554 _ => mismatch(),
9555 },
9556 ValueRef::Real(_) => match column.ty {
9557 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
9558 _ => mismatch(),
9559 },
9560 ValueRef::Text(t) => {
9561 std::str::from_utf8(t)
9562 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
9563 match column.ty {
9564 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
9565 Ok(ToSqlOutput::Borrowed(value))
9566 }
9567 _ => mismatch(),
9568 }
9569 }
9570 ValueRef::Blob(_) => match column.ty {
9571 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
9573 _ => mismatch(),
9574 },
9575 }
9576}
9577
9578pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
9579 use rusqlite::types::ValueRef;
9580 match value {
9581 ValueRef::Null => Value::Null,
9582 ValueRef::Integer(i) => match column.ty {
9583 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
9584 ssp2::segment::ColumnType::Float => {
9585 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
9586 }
9587 _ => Value::from(i),
9588 },
9589 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
9590 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
9591 ValueRef::Blob(b) => {
9592 let mut map = Map::new();
9593 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
9594 Value::Object(map)
9595 }
9596 }
9597}
9598
9599fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
9603 Ok(match value {
9604 Value::Null => SqlValue::Null,
9605 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
9606 Value::Number(n) => {
9607 if let Some(i) = n.as_i64() {
9608 SqlValue::Integer(i)
9609 } else if let Some(f) = n.as_f64() {
9610 SqlValue::Real(f)
9611 } else {
9612 return Err(format!("query param number {n} is out of range"));
9613 }
9614 }
9615 Value::String(s) => SqlValue::Text(s.clone()),
9616 Value::Object(_) => {
9617 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
9618 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
9619 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
9620 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
9621 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
9622 })?)
9623 } else {
9624 return Err(
9625 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
9626 .to_owned(),
9627 );
9628 }
9629 }
9630 Value::Array(_) => return Err("query array params are not supported".to_owned()),
9631 })
9632}
9633
9634fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
9639 use rusqlite::types::ValueRef;
9640 match value {
9641 ValueRef::Null => Value::Null,
9642 ValueRef::Integer(i) => {
9643 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
9647 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
9648 Value::from(i)
9649 } else {
9650 let mut map = Map::new();
9651 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
9652 Value::Object(map)
9653 }
9654 }
9655 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
9656 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
9657 ValueRef::Blob(b) => {
9658 let mut map = Map::new();
9659 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
9660 Value::Object(map)
9661 }
9662 }
9663}
9664
9665fn query_connection(
9667 conn: &Connection,
9668 sql: &str,
9669 params: &[QueryValue],
9670) -> Result<Vec<QueryRow>, QueryReadFailure> {
9671 crate::query_guard::assert_read_only_query(sql)?;
9672 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
9673 let bound: Vec<SqlValue> = params
9674 .iter()
9675 .map(json_param_to_sql)
9676 .collect::<Result<_, _>>()?;
9677 let mut stmt = conn.prepare(&lowered_sql)?;
9678 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
9679 let bound_refs: Vec<&dyn rusqlite::ToSql> =
9680 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
9681 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
9682 let mut out = Vec::new();
9683 while let Some(row) = sql_rows.next()? {
9684 let mut record = Map::new();
9685 for (i, name) in column_names.iter().enumerate() {
9686 let value = row.get_ref(i)?;
9687 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
9688 }
9689 out.push(record);
9690 }
9691 Ok(out)
9692}
9693
9694fn persisted_window_state(
9695 conn: &Connection,
9696 base: &WindowBase,
9697) -> Result<WindowState, QueryReadFailure> {
9698 let mut stmt = conn.prepare(
9699 "SELECT windows.unit, subscriptions.state_json
9700 FROM _syncular_windows AS windows
9701 JOIN _syncular_subscriptions AS subscriptions
9702 ON subscriptions.id = windows.sub_id
9703 WHERE windows.base = ?1
9704 ORDER BY windows.unit ASC",
9705 )?;
9706 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
9707 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9708 })?;
9709 let mut units = Vec::new();
9710 let mut pending = Vec::new();
9711 for row in rows {
9712 let (unit, raw) = row?;
9713 let state: Value = serde_json::from_str(&raw)
9714 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
9715 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
9716 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
9717 || state
9718 .get("bootstrapState")
9719 .is_some_and(|value| !value.is_null());
9720 if is_pending {
9721 pending.push(unit.clone());
9722 }
9723 units.push(unit);
9724 }
9725 Ok(WindowState { units, pending })
9726}
9727
9728#[derive(Debug, Clone, Copy)]
9730pub struct SnapshotStatement<'a> {
9731 pub sql: &'a str,
9732 pub params: &'a [Value],
9733}
9734
9735#[derive(Debug, Clone, Default)]
9739pub struct SnapshotReadRequest<'a> {
9740 pub statements: Vec<SnapshotStatement<'a>>,
9741 pub coverage: &'a [WindowCoverage],
9742 pub subscriptions: Vec<String>,
9743 pub commit_ids: Vec<String>,
9744}
9745
9746#[cfg(test)]
9747thread_local! {
9748 static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
9752 std::cell::RefCell::new(None);
9753}
9754
9755#[cfg(test)]
9756fn run_snapshot_read_hook() {
9757 SNAPSHOT_READ_HOOK.with(|slot| {
9758 if let Some(hook) = slot.borrow_mut().take() {
9759 hook();
9760 }
9761 });
9762}
9763
9764fn snapshot_read_connection(
9769 conn: &Connection,
9770 request: &SnapshotReadRequest<'_>,
9771) -> Result<SnapshotRead, QueryReadFailure> {
9772 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
9773 let corrupt = |message: &str| QueryReadFailure {
9774 code: Some("sync.local_corrupt"),
9775 sqlite_code: None,
9776 sqlite_message: None,
9777 rollback_failure: None,
9778 message: message.to_owned(),
9779 };
9780 let stored_failure = |error: rusqlite::Error, message: &str| match error {
9781 rusqlite::Error::Utf8Error(..)
9782 | rusqlite::Error::InvalidColumnType(..)
9783 | rusqlite::Error::FromSqlConversionFailure(..)
9784 | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
9785 _ => QueryReadFailure::from(error),
9786 };
9787 let result: Result<SnapshotRead, QueryReadFailure> = (|| {
9788 let revision: Option<String> = conn
9791 .query_row(
9792 "SELECT value FROM _syncular_meta WHERE key = ?1",
9793 rusqlite::params![LOCAL_REVISION_KEY],
9794 |row| row.get(0),
9795 )
9796 .optional()
9797 .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
9798 let revision = match revision {
9799 None => "0".to_owned(),
9800 Some(value)
9801 if value == "0"
9802 || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
9803 && value.bytes().all(|b| b.is_ascii_digit())
9804 && value.parse::<u64>().is_ok()) =>
9805 {
9806 value
9807 }
9808 Some(_) => return Err(corrupt("persisted local revision is invalid")),
9809 };
9810 let mut queries = Vec::with_capacity(request.statements.len());
9811 for statement in &request.statements {
9812 queries.push(query_connection(conn, statement.sql, statement.params)?);
9813 #[cfg(test)]
9814 run_snapshot_read_hook();
9815 }
9816 let mut pending = Vec::new();
9817 let mut missing = Vec::new();
9818 for requested in request.coverage {
9819 let base_key = window_base_key(&requested.base);
9820 let state = persisted_window_state(conn, &requested.base)?;
9821 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
9822 let reference = WindowUnitRef {
9823 base_key: base_key.clone(),
9824 unit: unit.clone(),
9825 };
9826 if !state.units.iter().any(|held| held == &unit) {
9827 missing.push(reference);
9828 } else if state.pending.iter().any(|held| held == &unit) {
9829 pending.push(reference);
9830 }
9831 }
9832 }
9833 let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
9834 for id in &request.subscriptions {
9835 let row: Option<(String, String)> = conn
9836 .query_row(
9837 "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
9838 rusqlite::params![id],
9839 |row| Ok((row.get(0)?, row.get(1)?)),
9840 )
9841 .optional()
9842 .map_err(|error| {
9843 stored_failure(error, "persisted subscription state is invalid")
9844 })?;
9845 let Some((table, raw)) = row else {
9846 subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
9847 continue;
9848 };
9849 let state: Value = serde_json::from_str(&raw)
9852 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
9853 let status = match state.get("status").and_then(Value::as_str) {
9854 Some(value @ ("active" | "revoked" | "failed")) => value,
9855 _ => return Err(corrupt("persisted subscription state is invalid")),
9856 };
9857 let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
9858 return Err(corrupt("persisted subscription state is invalid"));
9859 };
9860 if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
9861 return Err(corrupt("persisted subscription state is invalid"));
9862 }
9863 let effective_scopes = state
9864 .get("effectiveScopes")
9865 .filter(|value| !value.is_null());
9866 if let Some(scopes) = effective_scopes {
9867 json_to_scope_map(scopes)
9868 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
9869 }
9870 let has_resume_token = match state.get("bootstrapState") {
9871 None | Some(Value::Null) => false,
9872 Some(Value::String(_)) => true,
9873 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
9874 };
9875 let reason_code = match state.get("reasonCode") {
9878 None | Some(Value::Null) => None,
9879 Some(Value::String(value)) => Some(value.clone()),
9880 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
9881 };
9882 let active = status == "active";
9883 subscriptions.push(SubscriptionCatchup::Known {
9884 id: id.clone(),
9885 table,
9886 status: status.to_owned(),
9887 cursor,
9888 has_resume_token,
9889 bootstrap_complete: active && cursor >= 0 && !has_resume_token,
9890 known_pending_pages: active && (has_resume_token || cursor < 0),
9891 effective_scopes: effective_scopes.cloned(),
9892 reason_code,
9893 });
9894 }
9895 let mut deliveries = Vec::with_capacity(request.commit_ids.len());
9896 for id in &request.commit_ids {
9897 let pending: bool = conn.query_row(
9898 "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
9899 rusqlite::params![id],
9900 |row| row.get(0),
9901 )?;
9902 if pending {
9903 deliveries.push(CommitDelivery::Pending {
9904 client_commit_id: id.clone(),
9905 });
9906 continue;
9907 }
9908 let row = conn
9909 .query_row(
9910 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
9911 resolution, resolved_at_ms, replacement_client_commit_id
9912 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
9913 rusqlite::params![id],
9914 StoredCommitOutcomeRow::from_row,
9915 )
9916 .optional()
9917 .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
9918 deliveries.push(match row {
9919 Some(row) => CommitDelivery::Known {
9920 client_commit_id: id.clone(),
9921 outcome: SyncClient::outcome_from_row(row)
9922 .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
9923 },
9924 None => CommitDelivery::Unknown {
9925 client_commit_id: id.clone(),
9926 },
9927 });
9928 }
9929 Ok(SnapshotRead {
9930 revision,
9931 queries,
9932 coverage: CoverageSnapshot {
9933 complete: pending.is_empty() && missing.is_empty(),
9934 pending,
9935 missing,
9936 },
9937 subscriptions,
9938 deliveries,
9939 })
9940 })();
9941 match result {
9942 Ok(read) => {
9943 conn.execute_batch("RELEASE syncular_snapshot_read")?;
9944 Ok(read)
9945 }
9946 Err(mut error) => {
9947 if let Err(rollback) = conn
9948 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
9949 {
9950 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
9951 }
9952 Err(error)
9953 }
9954 }
9955}
9956
9957fn snapshot_connection(
9958 conn: &Connection,
9959 sql: &str,
9960 params: &[Value],
9961 coverage: &[WindowCoverage],
9962) -> Result<QuerySnapshot, QueryReadFailure> {
9963 let read = snapshot_read_connection(
9964 conn,
9965 &SnapshotReadRequest {
9966 statements: vec![SnapshotStatement { sql, params }],
9967 coverage,
9968 subscriptions: Vec::new(),
9969 commit_ids: Vec::new(),
9970 },
9971 )?;
9972 Ok(QuerySnapshot {
9973 revision: read.revision,
9974 rows: read
9975 .queries
9976 .into_iter()
9977 .next()
9978 .expect("single-query snapshot returns one result"),
9979 coverage: read.coverage,
9980 })
9981}
9982
9983pub struct FileQuerySnapshotReader {
9988 path: String,
9989 conn: Option<Connection>,
9990}
9991
9992impl FileQuerySnapshotReader {
9993 #[must_use]
9994 pub fn new(path: impl Into<String>) -> Self {
9995 Self {
9996 path: path.into(),
9997 conn: None,
9998 }
9999 }
10000
10001 fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
10002 if self.conn.is_none() {
10003 let conn = Connection::open_with_flags(
10004 &self.path,
10005 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
10006 )
10007 .map_err(QueryReadFailure::from)?;
10008 conn.busy_timeout(std::time::Duration::from_millis(250))
10009 .map_err(QueryReadFailure::from)?;
10010 self.conn = Some(conn);
10011 }
10012 self.conn
10013 .as_ref()
10014 .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
10015 }
10016
10017 pub fn snapshot_read(
10020 &mut self,
10021 request: &SnapshotReadRequest<'_>,
10022 ) -> Result<SnapshotRead, QueryReadFailure> {
10023 snapshot_read_connection(self.connection()?, request)
10024 }
10025
10026 pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
10029 Ok(self
10030 .snapshot_read(&SnapshotReadRequest::default())?
10031 .revision)
10032 }
10033
10034 pub fn subscription_catchup(
10036 &mut self,
10037 id: &str,
10038 ) -> Result<SubscriptionCatchup, QueryReadFailure> {
10039 let read = self.snapshot_read(&SnapshotReadRequest {
10040 subscriptions: vec![id.to_owned()],
10041 ..Default::default()
10042 })?;
10043 Ok(read
10044 .subscriptions
10045 .into_iter()
10046 .next()
10047 .expect("one requested catch-up"))
10048 }
10049
10050 pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
10053 let read = self.snapshot_read(&SnapshotReadRequest {
10054 commit_ids: vec![id.to_owned()],
10055 ..Default::default()
10056 })?;
10057 Ok(read
10058 .deliveries
10059 .into_iter()
10060 .next()
10061 .expect("one requested delivery"))
10062 }
10063
10064 pub fn query_snapshot(
10067 &mut self,
10068 sql: &str,
10069 params: &[Value],
10070 coverage: &[WindowCoverage],
10071 ) -> Result<QuerySnapshot, QueryReadFailure> {
10072 snapshot_connection(self.connection()?, sql, params, coverage)
10073 }
10074}
10075
10076fn validate_authority_reads(
10077 schema: &ClientSchema,
10078 declarations: &[crate::api::AuthorityReadDeclaration],
10079) -> Result<(), String> {
10080 let mut seen = BTreeSet::new();
10081 for read in declarations {
10082 let table = schema
10083 .table(&read.table)
10084 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
10085 let plain = |name: &str| {
10086 table.columns.iter().enumerate().any(|(i, c)| {
10087 c.name == name
10088 && name != "authority_version"
10089 && !name.starts_with("_sync")
10090 && !table.encrypted_columns.iter().any(|e| e.index == i)
10091 && matches!(
10092 c.ty,
10093 ssp2::segment::ColumnType::String
10094 | ssp2::segment::ColumnType::Integer
10095 | ssp2::segment::ColumnType::Float
10096 | ssp2::segment::ColumnType::Boolean
10097 | ssp2::segment::ColumnType::Json
10098 )
10099 })
10100 };
10101 if !seen.insert(&read.table)
10102 || read.columns.is_empty()
10103 || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
10104 || !read.columns.contains(&table.primary_key)
10105 || !read.columns.iter().all(|c| plain(c))
10106 || read.scopes.is_empty()
10107 || !read.scopes.iter().all(|(v, values)| {
10108 table
10109 .scope_column(v)
10110 .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
10111 && !values.is_empty()
10112 && values.iter().all(|v| !v.is_empty() && v != "*")
10113 })
10114 {
10115 return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
10116 }
10117 }
10118 Ok(())
10119}
10120
10121fn schema_marker_read_failure(error: rusqlite::Error) -> String {
10126 let failure = QueryReadFailure::from(error);
10127 if failure.code.is_some() {
10128 failure.to_string()
10129 } else {
10130 "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
10131 }
10132}
10133
10134fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
10138 if limits.outcome_retention_max_entries == Some(0) {
10139 return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
10140 }
10141 if [
10142 limits.max_push_commits_per_request,
10143 limits.max_push_operations_per_request,
10144 limits.max_push_request_bytes,
10145 ]
10146 .into_iter()
10147 .flatten()
10148 .any(|value| value == 0 || value > u32::MAX as usize)
10149 {
10150 return Err(
10151 "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
10152 );
10153 }
10154 Ok(())
10155}
10156
10157fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
10158 let corrupt =
10159 || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
10160 let table_type: Option<String> = conn
10161 .query_row(
10162 "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
10163 [],
10164 |row| row.get(0),
10165 )
10166 .optional()
10167 .map_err(schema_marker_read_failure)?;
10168 let Some(table_type) = table_type else {
10169 return Ok(None);
10170 };
10171 if table_type != "table" {
10172 return Err(corrupt());
10173 }
10174 let mut statement = conn
10178 .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
10179 .map_err(schema_marker_read_failure)?;
10180 let mut rows = statement
10181 .query([LOCAL_SCHEMA_VERSION_KEY])
10182 .map_err(schema_marker_read_failure)?;
10183 let value: Option<String> = rows
10184 .next()
10185 .map_err(schema_marker_read_failure)?
10186 .map(|row| row.get(0))
10187 .transpose()
10188 .map_err(schema_marker_read_failure)?;
10189 if rows.next().map_err(schema_marker_read_failure)?.is_some() {
10190 return Err(corrupt());
10191 }
10192 let Some(value) = value else {
10193 let has_descriptor = conn
10194 .query_row(
10195 "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
10196 [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
10197 |_| Ok(()),
10198 )
10199 .optional()
10200 .map_err(schema_marker_read_failure)?
10201 .is_some();
10202 if has_descriptor {
10203 return Err(corrupt());
10204 }
10205 return Ok(None);
10206 };
10207 if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
10208 || !value.bytes().all(|b| b.is_ascii_digit())
10209 {
10210 return Err(corrupt());
10211 }
10212 let version = value.parse::<i32>().map_err(|_| corrupt())?;
10213 if version > requested {
10214 return Err(
10215 "client.schema_downgrade: persisted local schema is newer than the requested schema"
10216 .to_owned(),
10217 );
10218 }
10219 Ok(Some(version))
10220}
10221
10222impl SyncClient {
10223 pub fn new_with_identity(
10224 client_id: Option<String>,
10225 schema_json: &Value,
10226 limits: ClientLimits,
10227 ) -> Result<Self, String> {
10228 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
10229 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
10230 Self::with_connection(resolved, schema_json, limits, conn)
10231 }
10232
10233 pub fn new(
10234 client_id: String,
10235 schema_json: &Value,
10236 limits: ClientLimits,
10237 ) -> Result<Self, String> {
10238 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
10239 Self::with_connection(client_id, schema_json, limits, conn)
10240 }
10241
10242 pub fn open_path(
10248 client_id: String,
10249 schema_json: &Value,
10250 limits: ClientLimits,
10251 path: &str,
10252 ) -> Result<Self, String> {
10253 validate_client_limits(&limits)?;
10254 parse_schema_json(schema_json)?;
10255 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
10256 Self::with_connection(client_id, schema_json, limits, conn)
10257 }
10258
10259 pub fn open_path_with_identity(
10260 client_id: Option<String>,
10261 schema_json: &Value,
10262 limits: ClientLimits,
10263 path: &str,
10264 ) -> Result<Self, String> {
10265 validate_client_limits(&limits)?;
10266 parse_schema_json(schema_json)?;
10269 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
10270 conn.busy_timeout(std::time::Duration::from_millis(250))
10278 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
10279 let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
10280 let journal_mode: String = client
10281 .conn
10282 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
10283 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10284 if journal_mode != "wal" {
10285 return Err(
10286 "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
10287 );
10288 }
10289 Ok(client)
10290 }
10291
10292 #[must_use]
10293 pub fn client_id(&self) -> &str {
10294 &self.client_id
10295 }
10296
10297 pub fn with_connection(
10304 client_id: String,
10305 schema_json: &Value,
10306 limits: ClientLimits,
10307 conn: Connection,
10308 ) -> Result<Self, String> {
10309 Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
10310 }
10311
10312 fn with_connection_identity(
10315 client_id: Option<String>,
10316 schema_json: &Value,
10317 limits: ClientLimits,
10318 conn: Connection,
10319 ) -> Result<Self, String> {
10320 validate_client_limits(&limits)?;
10321 let schema = parse_schema_json(schema_json)?;
10322 validate_authority_reads(&schema, &limits.authority_reads)?;
10323 let previous_version = match limits.previous_version_context {
10326 Some(config) => {
10327 config.validate()?;
10328 Some(config)
10329 }
10330 None => None,
10331 };
10332 let mut client = SyncClient {
10333 #[cfg(feature = "bench-internals")]
10334 benchmark_phases: Recorder::default(),
10335 conn,
10336 schema,
10337 client_id: client_id
10338 .clone()
10339 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
10340 limits,
10341 subs: Vec::new(),
10342 outbox: Vec::new(),
10343 failed_commits: Vec::new(),
10344 retain_failed_commits: false,
10345 conflicts: Vec::new(),
10346 rejections: Vec::new(),
10347 schema_floor: None,
10348 lease_state: None,
10349 stopped: false,
10350 upgrading: false,
10351 sync_needed: false,
10352 realtime_policy: RealtimePolicy::Optional,
10353 realtime_state: RealtimeState::Disconnected,
10354 realtime_reason_code: None,
10355 realtime_retry_delay_ms: None,
10356 presence: HashMap::new(),
10357 now_ms: None,
10358 encryption: crate::values::EncryptionConfig::default(),
10359 security_preflight: false,
10360 transport_enabled: true,
10361 previous_version,
10362 insert_sql: RefCell::new(HashMap::new()),
10363 overlay_dirty: OverlayDirty::clean(),
10364 storage_failure: RefCell::new(None),
10365 #[cfg(test)]
10366 overlay_rebuild_count: Cell::new(0),
10367 #[cfg(test)]
10368 acknowledged_replay_count: Cell::new(0),
10369 #[cfg(test)]
10370 outcome_prune_count: Cell::new(0),
10371 progress: ProgressObserver::default(),
10372 change_queue: VecDeque::new(),
10373 sync_intent_queue: VecDeque::new(),
10374 retry_delay_ms: 250,
10375 round_retry_delay_ms: None,
10376 active_round: None,
10377 last_round: None,
10378 last_change: None,
10379 query_failures: Vec::new(),
10380 };
10381 client
10385 .conn
10386 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
10387 client
10393 .conn
10394 .execute_batch("BEGIN IMMEDIATE")
10395 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10396 let startup = (|| -> Result<(), String> {
10397 let marker = read_local_schema_version(&client.conn, client.schema.version)?;
10400 client.create_bookkeeping_tables()?;
10403 match client.get_meta(CLIENT_ID_KEY) {
10404 Some(existing) => match client_id {
10405 Some(requested) if existing != requested => {
10406 return Err(format!(
10407 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
10408 ))
10409 }
10410 _ => client.client_id = existing,
10411 },
10412 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
10413 }
10414 client.restore_persisted_state()?;
10415 client.failed_commits = client.load_failed_commits()?;
10416 match marker {
10417 None => {
10418 client.create_synced_tables()?;
10419 client.persist_schema_version()?;
10420 client.save_subscription_scope_schema();
10421 }
10422 Some(version) if version == client.schema.version => {
10423 client.create_synced_tables()?;
10424 set_local_schema_descriptor(&client.conn, &client.schema)
10428 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10429 client.save_subscription_scope_schema();
10430 }
10431 Some(_) => client.run_schema_reset()?,
10432 }
10433 client.reconcile_previous_version_at_boot()?;
10439 client.clear_satisfied_persisted_schema_floor();
10440 client.prune_unknown_subscriptions(false)?;
10441 client.drop_unencodable_outbox(false, None)?;
10450 if marker == Some(client.schema.version)
10451 && (!client.outbox.is_empty()
10452 || !client.failed_commits.is_empty()
10453 || client.has_acknowledged_rows())
10454 {
10455 client.overlay_dirty.set(true);
10459 client.rebuild_overlay()?;
10460 }
10461 client.enqueue_startup_sync_if_needed()?;
10468 Ok(())
10469 })();
10470 match startup {
10471 Ok(()) => match client.conn.execute_batch("COMMIT") {
10472 Ok(()) => Ok(client),
10473 Err(error) => {
10474 let failure = Self::sqlite_failure(&client.storage_failure, error);
10475 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
10476 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
10478 }
10479 Err(failure)
10480 }
10481 },
10482 Err(error) => {
10483 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
10484 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
10486 }
10487 Err(error)
10488 }
10489 }
10490 }
10491
10492 pub fn set_now_ms(&mut self, now_ms: i64) {
10495 self.now_ms = Some(now_ms);
10496 }
10497
10498 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
10502 self.encryption = encryption;
10503 }
10504
10505 #[must_use]
10506 pub fn security_lifecycle(&self) -> &'static str {
10507 if self.security_preflight {
10508 "preflight"
10509 } else {
10510 "active"
10511 }
10512 }
10513
10514 #[must_use]
10515 pub fn security_preflight(&self) -> bool {
10516 self.security_preflight
10517 }
10518
10519 pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
10521 if self.transport_enabled != enabled {
10522 self.transport_enabled = enabled;
10523 self.sync_intent_queue.clear();
10524 self.sync_intent_queue
10525 .push_back(if enabled && !self.security_preflight {
10526 SyncIntent::Interactive
10527 } else {
10528 SyncIntent::None
10529 });
10530 }
10531 if !enabled && self.active_round.is_none() {
10533 self.disconnect_realtime(transport);
10534 }
10535 }
10536
10537 pub fn transport_enabled(&self) -> bool {
10538 self.transport_enabled
10539 }
10540
10541 pub fn begin_security_preflight(&mut self) {
10548 self.seal_security_on_teardown();
10549 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
10552 }
10553
10554 pub fn seal_security_on_teardown(&mut self) {
10564 self.cancel_sync_round();
10565 self.security_preflight = true;
10566 self.encryption = crate::values::EncryptionConfig::default();
10567 self.sync_intent_queue.clear();
10568 }
10569
10570 pub fn activate_security(
10572 &mut self,
10573 encryption: crate::values::EncryptionConfig,
10574 ) -> Result<(), String> {
10575 if !self.security_preflight {
10576 return Err(
10577 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
10578 );
10579 }
10580 self.enqueue_startup_sync_if_needed()?;
10581 self.encryption = encryption;
10582 self.security_preflight = false;
10583 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
10584 Ok(())
10585 }
10586
10587 fn clock_now_ms(&self) -> i64 {
10588 self.now_ms.unwrap_or_else(|| {
10589 std::time::SystemTime::now()
10590 .duration_since(std::time::UNIX_EPOCH)
10591 .map(|d| d.as_millis() as i64)
10592 .unwrap_or(0)
10593 })
10594 }
10595
10596 fn create_bookkeeping_tables(&self) -> Result<(), String> {
10597 self.conn
10599 .execute_batch(
10600 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
10601 seq INTEGER PRIMARY KEY AUTOINCREMENT,
10602 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
10603 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
10604 tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
10605 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
10606 commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
10607 commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
10608 PRIMARY KEY(commit_id, idx));
10609 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
10610 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
10611 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
10612 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
10613 seq INTEGER PRIMARY KEY AUTOINCREMENT,
10614 client_commit_id TEXT NOT NULL UNIQUE,
10615 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
10616 recorded_at_ms INTEGER NOT NULL,
10617 results_json TEXT NOT NULL,
10618 operations_json TEXT,
10619 resolution TEXT NOT NULL DEFAULT 'active'
10620 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
10621 resolved_at_ms INTEGER,
10622 replacement_client_commit_id TEXT);
10623 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
10624 ON _syncular_commit_outcomes(resolution, seq);
10625 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
10626 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
10627 CREATE TABLE IF NOT EXISTS _syncular_meta (
10628 key TEXT PRIMARY KEY, value TEXT NOT NULL);
10629 CREATE TABLE IF NOT EXISTS _syncular_windows (
10630 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
10631 PRIMARY KEY (base, unit));
10632 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
10633 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
10634 effective_scopes TEXT NOT NULL);
10635 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
10636 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
10637 PRIMARY KEY(commit_id, blob_id));
10638 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
10639 ON _syncular_blob_commit_refs(blob_id);",
10640 )
10641 .map_err(|e| e.to_string())?;
10642 let _ = self
10645 .conn
10646 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
10647 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
10648 self.set_meta(LOCAL_REVISION_KEY, "0");
10649 }
10650 if self.schema_has_blobs() {
10653 let exists = self
10654 .conn
10655 .query_row(
10656 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
10657 [],
10658 |row| row.get::<_, bool>(0),
10659 )
10660 .map_err(|e| e.to_string())?;
10661 if exists {
10662 let mut statement = self
10663 .conn
10664 .prepare("PRAGMA table_info(_syncular_blobs)")
10665 .map_err(|e| e.to_string())?;
10666 let mut columns = statement
10667 .query_map([], |row| row.get::<_, String>(1))
10668 .map_err(|e| e.to_string())?
10669 .collect::<Result<Vec<_>, _>>()
10670 .map_err(|e| e.to_string())?;
10671 columns.sort();
10672 let mut expected = vec![
10673 "blob_id",
10674 "byte_length",
10675 "bytes",
10676 "created_at_ms",
10677 "media_type",
10678 ];
10679 expected.sort_unstable();
10680 if columns != expected {
10681 return Err(
10682 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
10683 );
10684 }
10685 } else {
10686 self.conn
10687 .execute_batch(
10688 "CREATE TABLE _syncular_blobs(
10689 blob_id TEXT PRIMARY KEY,
10690 bytes BLOB NOT NULL,
10691 byte_length INTEGER NOT NULL,
10692 media_type TEXT,
10693 created_at_ms INTEGER NOT NULL);",
10694 )
10695 .map_err(|e| e.to_string())?;
10696 }
10697 self.conn
10698 .execute_batch(
10699 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
10700 blob_id TEXT PRIMARY KEY,
10701 media_type TEXT,
10702 created_at_ms INTEGER NOT NULL);",
10703 )
10704 .map_err(|e| e.to_string())?;
10705 }
10706 Ok(())
10707 }
10708
10709 fn schema_has_blobs(&self) -> bool {
10711 self.schema
10712 .tables
10713 .iter()
10714 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
10715 }
10716
10717 fn get_meta(&self, key: &str) -> Option<String> {
10720 meta_get(&self.conn, key)
10721 }
10722
10723 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
10724 self.conn
10725 .query_row(
10726 "SELECT value FROM _syncular_meta WHERE key = ?1",
10727 rusqlite::params![key],
10728 |row| row.get::<_, String>(0),
10729 )
10730 .optional()
10731 .map_err(|_| {
10732 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
10733 })
10734 }
10735
10736 fn persist_schema_version(&self) -> Result<(), String> {
10739 self.conn
10740 .execute(
10741 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
10742 rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
10743 )
10744 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10745 set_local_schema_descriptor(&self.conn, &self.schema)
10746 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
10747 }
10748
10749 fn set_meta(&self, key: &str, value: &str) {
10750 meta_set(&self.conn, key, value);
10751 }
10752
10753 fn delete_meta(&self, key: &str) {
10754 meta_delete(&self.conn, key);
10755 }
10756
10757 fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
10759 let mut stmt = self
10760 .conn
10761 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
10762 .map_err(|error| error.to_string())?;
10763 let rows = stmt
10764 .query_map([], |row| {
10765 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10766 })
10767 .map_err(|error| error.to_string())?;
10768 let mut commits = Vec::new();
10769 for row in rows {
10770 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
10771 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
10772 format!("invalid persisted outbox {client_commit_id:?}: {error}")
10773 })?;
10774 let mut ops = Vec::with_capacity(entries.len());
10775 for entry in entries {
10776 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
10777 ops.push(OutboxOp {
10778 upsert: op == "upsert",
10779 table: entry
10780 .get("table")
10781 .and_then(Value::as_str)
10782 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
10783 .to_owned(),
10784 row_id: entry
10785 .get("rowId")
10786 .and_then(Value::as_str)
10787 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
10788 .to_owned(),
10789 base_version: entry.get("baseVersion").and_then(Value::as_i64),
10790 values: entry.get("values").and_then(Value::as_object).cloned(),
10793 });
10794 }
10795 commits.push(OutboxCommit {
10796 client_commit_id,
10797 ops,
10798 });
10799 }
10800 Ok(commits)
10801 }
10802
10803 fn restore_persisted_state(&mut self) -> Result<(), String> {
10804 self.subs = {
10805 let mut stmt = self
10806 .conn
10807 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
10808 .map_err(|error| error.to_string())?;
10809 let rows = stmt
10810 .query_map([], |row| {
10811 Ok((
10812 row.get::<_, String>(0)?,
10813 row.get::<_, String>(1)?,
10814 row.get::<_, String>(2)?,
10815 ))
10816 })
10817 .map_err(|error| error.to_string())?;
10818 let mut subscriptions = Vec::new();
10819 for row in rows {
10820 let (id, table, raw) = row.map_err(|error| error.to_string())?;
10821 let state: Value = serde_json::from_str(&raw)
10822 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
10823 let requested = json_to_scope_map(
10824 state.get("requested").unwrap_or(&Value::Object(Map::new())),
10825 )?;
10826 let effective = state
10827 .get("effectiveScopes")
10828 .filter(|value| !value.is_null())
10829 .map(json_to_scope_map)
10830 .transpose()?;
10831 subscriptions.push(Subscription {
10832 id,
10833 table,
10834 requested,
10835 params: state
10836 .get("params")
10837 .and_then(Value::as_str)
10838 .map(str::to_owned),
10839 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
10840 bootstrap_state: state
10841 .get("bootstrapState")
10842 .and_then(Value::as_str)
10843 .map(str::to_owned),
10844 state: SubState::parse(
10845 state
10846 .get("status")
10847 .and_then(Value::as_str)
10848 .unwrap_or("active"),
10849 ),
10850 reason_code: state
10851 .get("reasonCode")
10852 .and_then(Value::as_str)
10853 .map(str::to_owned),
10854 effective,
10855 synced_once: state
10856 .get("syncedOnce")
10857 .and_then(Value::as_bool)
10858 .unwrap_or(false),
10859 });
10860 }
10861 subscriptions
10862 };
10863
10864 self.outbox = self.load_outbox()?;
10865
10866 self.prune_commit_outcomes()?;
10867 let active = self.commit_outcomes(CommitOutcomeQuery {
10868 active_only: true,
10869 ..CommitOutcomeQuery::default()
10870 })?;
10871 self.conflicts = active
10872 .iter()
10873 .flat_map(|outcome| outcome.results.iter())
10874 .filter_map(|result| match result {
10875 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
10876 _ => None,
10877 })
10878 .collect();
10879 self.rejections = active
10880 .iter()
10881 .flat_map(|outcome| outcome.results.iter())
10882 .filter_map(|result| match result {
10883 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
10884 _ => None,
10885 })
10886 .collect();
10887
10888 self.lease_state = self
10889 .get_meta(LEASE_STATE_KEY)
10890 .map(|raw| serde_json::from_str(&raw))
10891 .transpose()
10892 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
10893 self.schema_floor = self
10894 .get_meta(SCHEMA_FLOOR_KEY)
10895 .map(|raw| serde_json::from_str(&raw))
10896 .transpose()
10897 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
10898 self.stopped = self.schema_floor.is_some();
10899 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
10902 self.security_preflight = true;
10903 }
10904 Ok(())
10905 }
10906
10907 fn save_subscription_scope_schema(&self) {
10908 let declarations: BTreeMap<_, BTreeMap<_, _>> = self
10909 .schema
10910 .tables
10911 .iter()
10912 .map(|table| {
10913 (
10914 table.name.clone(),
10915 table
10916 .scope_variables
10917 .iter()
10918 .map(|scope| {
10919 (
10920 scope.variable.clone(),
10921 [scope.prefix.clone(), scope.column.clone()],
10922 )
10923 })
10924 .collect(),
10925 )
10926 })
10927 .collect();
10928 self.set_meta(
10929 "subscriptionScopeSchema",
10930 &serde_json::to_string(&declarations).expect("scope schema serialization"),
10931 );
10932 }
10933
10934 fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
10936 let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
10937 self.get_meta("subscriptionScopeSchema")
10938 .map(|raw| serde_json::from_str(&raw))
10939 .transpose()
10940 .map_err(|error| format!("invalid persisted scope schema: {error}"))?
10941 } else {
10942 None
10943 };
10944 let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
10945 let Some(table) = self.schema.table(table_name) else {
10946 return false;
10947 };
10948 if requested
10949 .iter()
10950 .any(|(variable, _)| table.scope_column(variable).is_none())
10951 {
10952 return false;
10953 }
10954 if !bump {
10955 return true;
10956 }
10957 let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
10958 return false;
10959 };
10960 let variables: BTreeSet<_> = if requested.is_empty() {
10961 old.keys()
10962 .cloned()
10963 .chain(
10964 table
10965 .scope_variables
10966 .iter()
10967 .map(|scope| scope.variable.clone()),
10968 )
10969 .collect()
10970 } else {
10971 requested
10972 .iter()
10973 .map(|(variable, _)| variable.clone())
10974 .collect()
10975 };
10976 variables.iter().all(|variable| {
10977 let current = table
10978 .scope_variables
10979 .iter()
10980 .find(|scope| &scope.variable == variable);
10981 match (old.get(variable), current) {
10982 (Some(old), Some(current)) => {
10983 old == &[current.prefix.clone(), current.column.clone()]
10984 }
10985 _ => false,
10986 }
10987 })
10988 };
10989 let mut stale_ids: BTreeSet<_> = self
10990 .subs
10991 .iter()
10992 .filter(|sub| !compatible(&sub.table, &sub.requested))
10993 .map(|sub| sub.id.clone())
10994 .collect();
10995 for (id, table, effective) in self.load_pending_evictions()? {
10996 if !compatible(&table, &effective) {
10997 stale_ids.insert(id);
10998 }
10999 }
11000 for id in &stale_ids {
11001 for sql in [
11002 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
11003 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
11004 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
11005 ] {
11006 self.conn
11007 .execute(sql, rusqlite::params![id])
11008 .map_err(|error| error.to_string())?;
11009 }
11010 }
11011 self.subs.retain(|sub| !stale_ids.contains(&sub.id));
11012 Ok(())
11013 }
11014
11015 #[must_use]
11016 pub fn local_revision(&self) -> u64 {
11017 self.get_meta(LOCAL_REVISION_KEY)
11018 .and_then(|value| value.parse().ok())
11019 .unwrap_or(0)
11020 }
11021
11022 pub fn snapshot_read(
11028 &mut self,
11029 request: &SnapshotReadRequest<'_>,
11030 owner: Option<QueryOwner<'_>>,
11031 ) -> Result<SnapshotRead, QueryReadFailure> {
11032 let result = snapshot_read_connection(&self.conn, request);
11033 if let Some(owner) = owner {
11034 self.record_query_read(owner, result.as_ref().err());
11035 }
11036 result
11037 }
11038
11039 #[must_use]
11040 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
11041 SyncStatusSnapshot {
11042 current_schema_version: self.schema.version,
11043 outbox: self.outbox.len(),
11044 upgrading: self.upgrading,
11045 lease_state: self.lease_state.clone(),
11046 schema_floor: self.schema_floor.clone(),
11047 sync_needed: self.sync_needed,
11048 previous_version_context: self.previous_version_status(),
11049 }
11050 }
11051
11052 fn previous_version_status(&self) -> PreviousVersionStatus {
11055 let Some(replica_path) = self.previous_version_replica_path() else {
11056 return PreviousVersionStatus {
11057 present: false,
11058 created_at_ms: None,
11059 };
11060 };
11061 match crate::previous_version::read_previous_version_record(&replica_path) {
11062 Ok(Some(record)) => PreviousVersionStatus {
11063 present: true,
11064 created_at_ms: Some(record.created_at_ms),
11065 },
11066 _ => PreviousVersionStatus {
11067 present: std::path::Path::new(
11068 &crate::previous_version::previous_version_container_path(&replica_path),
11069 )
11070 .exists(),
11071 created_at_ms: None,
11072 },
11073 }
11074 }
11075
11076 pub fn previous_version_snapshot(
11082 &self,
11083 spec: &PreviousVersionReadSpec,
11084 ) -> Result<PreviousVersionSnapshot, String> {
11085 let now_ms = self.clock_now_ms();
11086 crate::previous_version::previous_version_snapshot(
11087 &self.conn,
11088 self.previous_version_replica_path().as_deref(),
11089 self.previous_version.as_ref(),
11090 PreviousVersionLifecycle {
11091 lease_inactive: self.previous_version_lease_inactive(now_ms),
11092 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
11093 coverage_complete: self.previous_version_coverage_complete(),
11094 },
11095 now_ms,
11096 self.schema.version,
11097 spec,
11098 )
11099 }
11100
11101 #[must_use]
11103 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
11104 crate::previous_version::stored_previous_version_audit(&self.conn)
11105 }
11106
11107 pub fn previous_version_discard(
11111 &self,
11112 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
11113 self.drop_previous_version()
11114 }
11115
11116 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
11120 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
11121 present: false,
11122 discarded: false,
11123 };
11124 let Some(replica_path) = self.previous_version_replica_path() else {
11125 return absent;
11126 };
11127 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
11128 .unwrap_or(absent)
11129 }
11130
11131 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
11133 let Some(lease) = &self.lease_state else {
11134 return false;
11135 };
11136 if lease.error_code.is_some() {
11137 return true;
11138 }
11139 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
11140 }
11141
11142 fn previous_version_coverage_complete(&self) -> bool {
11146 let active = self
11147 .subs
11148 .iter()
11149 .filter(|sub| sub.state == SubState::Active)
11150 .collect::<Vec<_>>();
11151 if active.is_empty() {
11152 return false;
11153 }
11154 active
11155 .iter()
11156 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
11157 }
11158
11159 fn previous_version_lifetime_check(&self) {
11163 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
11164 return;
11165 };
11166 let now_ms = self.clock_now_ms();
11167 if self.previous_version_lease_inactive(now_ms) {
11168 let _ = self.drop_previous_version();
11169 return;
11170 }
11171 let Some(replica_path) = self.previous_version_replica_path() else {
11172 return;
11173 };
11174 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
11175 else {
11176 return;
11177 };
11178 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
11179 let _ = self.drop_previous_version();
11180 return;
11181 }
11182 if now_ms - record.created_at_ms > config.max_age_ms {
11183 let _ = self.drop_previous_version();
11184 return;
11185 }
11186 if self.previous_version_coverage_complete() {
11187 let _ = self.drop_previous_version();
11188 }
11189 }
11190
11191 pub fn diagnostics_snapshot(
11192 &self,
11193 request: &ClientDiagnosticsRequest,
11194 ) -> Result<ClientDiagnosticsSnapshot, String> {
11195 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
11196 return Err(format!(
11197 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
11198 ));
11199 }
11200 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
11201 for sub in &self.subs {
11202 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
11203 let complete =
11204 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
11205 let state = match sub.state {
11206 SubState::Revoked => "revoked",
11207 SubState::Failed => "failed",
11208 SubState::Active if reset => "reset",
11209 SubState::Active if complete => "complete",
11210 SubState::Active => "bootstrapping",
11211 };
11212 subscriptions.insert(
11213 sub.id.clone(),
11214 DiagnosticSubscription {
11215 id: sub.id.clone(),
11216 table: sub.table.clone(),
11217 state: state.to_owned(),
11218 complete,
11219 cursor: Some(sub.cursor),
11220 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
11221 },
11222 );
11223 }
11224 for expected in &request.expected_subscriptions {
11225 if expected.id.is_empty() || expected.table.is_empty() {
11226 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
11227 }
11228 if subscriptions
11229 .get(&expected.id)
11230 .is_some_and(|registered| registered.table != expected.table)
11231 {
11232 subscriptions.insert(
11233 expected.id.clone(),
11234 DiagnosticSubscription {
11235 id: expected.id.clone(),
11236 table: expected.table.clone(),
11237 state: "failed".to_owned(),
11238 complete: false,
11239 cursor: None,
11240 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
11241 },
11242 );
11243 } else {
11244 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
11245 DiagnosticSubscription {
11246 id: expected.id.clone(),
11247 table: expected.table.clone(),
11248 state: "unregistered".to_owned(),
11249 complete: false,
11250 cursor: None,
11251 reason_code: None,
11252 }
11253 });
11254 }
11255 }
11256 let mut ordered_subscriptions = Vec::new();
11257 let mut included = BTreeSet::new();
11258 for expected in &request.expected_subscriptions {
11259 if included.insert(expected.id.clone()) {
11260 if let Some(subscription) = subscriptions.get(&expected.id) {
11261 ordered_subscriptions.push(subscription.clone());
11262 }
11263 }
11264 }
11265 for (id, subscription) in &subscriptions {
11266 if included.insert(id.clone()) {
11267 ordered_subscriptions.push(subscription.clone());
11268 }
11269 }
11270 let subscriptions_truncated =
11271 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
11272 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
11273 let captured_at_ms = self.clock_now_ms();
11274 let lease = if let Some(error_code) = self
11275 .lease_state
11276 .as_ref()
11277 .and_then(|state| state.error_code.clone())
11278 {
11279 ClientDiagnosticsLease {
11280 state: "stopped".to_owned(),
11281 expires_at_ms: self
11282 .lease_state
11283 .as_ref()
11284 .and_then(|state| state.expires_at_ms),
11285 error_code: Some(Self::diagnostic_code(&error_code)),
11286 }
11287 } else if let Some(expires_at_ms) = self
11288 .lease_state
11289 .as_ref()
11290 .and_then(|state| state.expires_at_ms)
11291 {
11292 ClientDiagnosticsLease {
11293 state: if expires_at_ms <= captured_at_ms {
11294 "expired".to_owned()
11295 } else {
11296 "active".to_owned()
11297 },
11298 expires_at_ms: Some(expires_at_ms),
11299 error_code: None,
11300 }
11301 } else {
11302 ClientDiagnosticsLease {
11303 state: "none".to_owned(),
11304 expires_at_ms: None,
11305 error_code: None,
11306 }
11307 };
11308 let connectivity = match self.last_round.as_ref() {
11309 _ if !self.transport_enabled && self.active_round.is_none() => "offline",
11310 Some(round) if round.status == "succeeded" => "online",
11311 Some(round)
11312 if round.status == "failed"
11313 && round
11314 .error_code
11315 .as_deref()
11316 .is_some_and(Self::retryable_transport_code) =>
11317 {
11318 "offline"
11319 }
11320 _ => "unknown",
11321 };
11322 Ok(ClientDiagnosticsSnapshot {
11323 version: CLIENT_DIAGNOSTICS_VERSION,
11324 captured_at_ms,
11325 host: ClientDiagnosticsHost {
11326 kind: "direct".to_owned(),
11327 role: "single".to_owned(),
11328 connectivity: connectivity.to_owned(),
11329 realtime: self.realtime_state,
11330 realtime_policy: self.realtime_policy,
11331 realtime_reason_code: self.realtime_reason_code.clone(),
11332 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
11333 },
11334 security_lifecycle: self.security_lifecycle().to_owned(),
11335 schema: ClientDiagnosticsSchema {
11336 current_version: self.schema.version,
11337 upgrading: self.upgrading,
11338 required_version: self
11339 .schema_floor
11340 .as_ref()
11341 .and_then(|floor| floor.required_schema_version),
11342 latest_version: self
11343 .schema_floor
11344 .as_ref()
11345 .and_then(|floor| floor.latest_schema_version),
11346 },
11347 replica: ClientDiagnosticsReplica {
11348 local_revision: self.local_revision().to_string(),
11349 sync_needed: self.sync_needed,
11350 pending_outbox: self.outbox.len(),
11351 },
11352 lease,
11353 subscriptions: ordered_subscriptions,
11354 subscriptions_truncated,
11355 last_round: self.last_round.clone(),
11356 last_change: self.last_change.clone(),
11357 storage: self.diagnostics_storage(),
11358 query_failures: self.query_failures.clone(),
11359 })
11360 }
11361
11362 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
11363 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
11364 let page_count: i64 = self
11365 .conn
11366 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
11367 let page_size: i64 = self
11368 .conn
11369 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
11370 let outbox_bytes: i64 = self
11372 .conn
11373 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
11374 .query_row([], |row| row.get(0))?;
11375 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
11376 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
11377 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
11378 let blob_bytes = if self.schema_has_blobs() {
11379 self.conn
11380 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
11381 .query_row([], |row| row.get(0))?
11382 } else {
11383 0
11384 };
11385 let pressure = self
11386 .limits
11387 .blob_cache_max_bytes
11388 .is_some_and(|limit| blob_bytes > limit);
11389 Ok(ClientDiagnosticsStorage {
11390 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
11391 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
11392 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
11393 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
11394 retained_outcome_entries: Some(outcome_entries.max(0)),
11395 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
11396 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
11397 })
11398 };
11399 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
11400 status: "unreadable".to_owned(),
11401 database_bytes_approx: None,
11402 pending_outbox_bytes_approx: None,
11403 retained_outcome_bytes_approx: None,
11404 retained_outcome_entries: None,
11405 blob_cache_bytes_approx: None,
11406 pressure_reason_code: None,
11407 })
11408 }
11409
11410 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
11411 self.change_queue.drain(..).collect()
11412 }
11413
11414 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
11415 let intents = self.sync_intent_queue.drain(..).collect();
11416 if self.transport_enabled {
11417 intents
11418 } else {
11419 vec![SyncIntent::None]
11420 }
11421 }
11422
11423 fn schedule_background_retry(&mut self) -> u64 {
11424 let delay_ms = self.retry_delay_ms;
11425 self.sync_intent_queue
11426 .push_back(SyncIntent::Background { delay_ms });
11427 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
11428 self.round_retry_delay_ms = Some(delay_ms);
11429 delay_ms
11430 }
11431
11432 fn reset_background_retry(&mut self) {
11433 self.retry_delay_ms = 250;
11434 }
11435
11436 fn retryable_transport_code(code: &str) -> bool {
11437 code == "transport.failed"
11438 || code == "transport.unavailable"
11439 || code == "sync.transport_failed"
11440 }
11441
11442 fn retryable_failure_code(code: &str) -> bool {
11447 Self::retryable_transport_code(code)
11448 || matches!(
11449 code,
11450 "sync.auth_required"
11451 | "sync.auth_lease_required"
11452 | "sync.auth_lease_revoked"
11453 | "sync.internal_error"
11454 | "sync.idempotency_cache_miss"
11455 | "sync.schema_not_ready"
11456 | "sync.segment_expired"
11457 | "sync.rate_limited"
11458 | "sync.websocket_connection_limit"
11459 )
11460 }
11461
11462 fn diagnostic_code(code: &str) -> String {
11463 let valid = !code.is_empty()
11464 && code.len() <= 96
11465 && code.contains('.')
11466 && code
11467 .bytes()
11468 .next()
11469 .is_some_and(|byte| byte.is_ascii_lowercase())
11470 && code.bytes().all(|byte| {
11471 byte.is_ascii_lowercase()
11472 || byte.is_ascii_digit()
11473 || matches!(byte, b'.' | b'_' | b'-')
11474 });
11475 if valid {
11476 code.to_owned()
11477 } else {
11478 "client.unknown_failure".to_owned()
11479 }
11480 }
11481
11482 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
11483 if self.sync_needed != value {
11484 if self.begin_observation("syncular_status").is_ok() {
11485 self.sync_needed = value;
11486 let batch = ChangeAccumulator {
11487 status: true,
11488 ..ChangeAccumulator::default()
11489 };
11490 if self.finish_observation("syncular_status", batch).is_err() {
11491 self.rollback_observation("syncular_status");
11492 }
11493 } else {
11494 self.sync_needed = value;
11495 }
11496 }
11497 if value && interactive {
11498 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11499 }
11500 }
11501
11502 fn sqlite_failure(
11503 storage_failure: &RefCell<Option<QueryReadFailure>>,
11504 error: rusqlite::Error,
11505 ) -> String {
11506 let message = error.to_string();
11507 let failure = QueryReadFailure::from(error);
11508 let code = failure.code;
11509 if code.is_some() && storage_failure.borrow().is_none() {
11510 *storage_failure.borrow_mut() = Some(failure);
11511 }
11512 match code {
11513 Some(code) => format!("{code}: {message}"),
11514 None => message,
11515 }
11516 }
11517
11518 fn begin_observation(&self, name: &str) -> Result<(), String> {
11519 self.conn
11520 .execute_batch(&format!("SAVEPOINT {name}"))
11521 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11522 }
11523
11524 fn rollback_observation(&self, name: &str) {
11525 if let Err(error) = self
11526 .conn
11527 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
11528 {
11529 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
11530 if failure.rollback_failure.is_none() {
11531 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
11532 }
11533 }
11534 }
11535 }
11536
11537 fn finish_observation(
11538 &mut self,
11539 name: &str,
11540 mut batch: ChangeAccumulator,
11541 ) -> Result<(), String> {
11542 #[cfg(feature = "bench-internals")]
11543 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
11544 if self.failed_commits.iter().any(|(commit, _)| {
11545 commit
11546 .ops
11547 .iter()
11548 .any(|operation| batch.tables.contains_key(&operation.table))
11549 }) {
11550 batch.outcomes = true;
11551 }
11552 if !batch.touched() {
11553 self.conn
11554 .execute_batch(&format!("RELEASE {name}"))
11555 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11556 return Ok(());
11557 }
11558 let revision = self
11559 .local_revision()
11560 .checked_add(1)
11561 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
11562 self.conn
11563 .execute(
11564 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11565 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
11566 )
11567 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11568 let status = batch.status.then(|| self.status_snapshot());
11569 let event = ClientChangeBatch {
11570 revision: revision.to_string(),
11571 tables: batch
11572 .tables
11573 .into_iter()
11574 .map(|(table, scope_keys)| TableChange {
11575 table,
11576 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
11577 })
11578 .collect(),
11579 windows: batch
11580 .windows
11581 .into_iter()
11582 .map(|((base_key, table), units)| WindowChange {
11583 base_key,
11584 table,
11585 units: units.into_iter().collect(),
11586 })
11587 .collect(),
11588 status,
11589 conflicts_changed: batch.conflicts,
11590 rejections_changed: batch.rejections,
11591 outcomes_changed: batch.outcomes,
11592 };
11593 self.conn
11594 .execute_batch(&format!("RELEASE {name}"))
11595 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11596 let mut diagnostic_tables = event
11597 .tables
11598 .iter()
11599 .map(|entry| entry.table.clone())
11600 .collect::<BTreeSet<_>>()
11601 .into_iter()
11602 .collect::<Vec<_>>();
11603 let mut diagnostic_windows = event
11604 .windows
11605 .iter()
11606 .map(|entry| entry.table.clone())
11607 .collect::<BTreeSet<_>>()
11608 .into_iter()
11609 .collect::<Vec<_>>();
11610 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
11611 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
11612 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
11613 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
11614 self.last_change = Some(DiagnosticLastChange {
11615 revision: event.revision.clone(),
11616 recorded_at_ms: self.clock_now_ms(),
11617 tables: diagnostic_tables,
11618 windows: diagnostic_windows,
11619 domains_truncated,
11620 status_changed: event.status.is_some(),
11621 conflicts_changed: event.conflicts_changed,
11622 rejections_changed: event.rejections_changed,
11623 outcomes_changed: event.outcomes_changed,
11624 });
11625 self.change_queue.push_back(event);
11626 Ok(())
11627 }
11628
11629 fn record_scope_map(
11630 &self,
11631 batch: &mut ChangeAccumulator,
11632 table_name: &str,
11633 scopes: &[(String, Vec<String>)],
11634 ) {
11635 let Some(table) = self.schema.table(table_name) else {
11636 return;
11637 };
11638 for (variable, values) in scopes {
11639 let Some(scope) = table
11640 .scope_variables
11641 .iter()
11642 .find(|scope| &scope.variable == variable)
11643 else {
11644 continue;
11645 };
11646 for value in values {
11647 batch.scope(table_name, format!("{}:{value}", scope.prefix));
11648 }
11649 }
11650 }
11651
11652 fn record_row_scopes(
11654 &self,
11655 batch: &mut ChangeAccumulator,
11656 table_name: &str,
11657 row_id: &str,
11658 base: bool,
11659 ) -> bool {
11660 let Some(table) = self.schema.table(table_name) else {
11661 return false;
11662 };
11663 if table.scope_variables.is_empty() {
11664 return false;
11665 }
11666 let columns = table
11667 .scope_variables
11668 .iter()
11669 .map(|scope| quote_ident(&scope.column))
11670 .collect::<Vec<_>>()
11671 .join(", ");
11672 let full_table = if base {
11673 base_table(table_name)
11674 } else {
11675 visible_table(table_name)
11676 };
11677 let sql = format!(
11678 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
11679 row_id_predicate(table)
11680 );
11681 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
11682 return false;
11683 };
11684 let values = stmt.query_row(rusqlite::params![row_id], |row| {
11685 let mut values = Vec::with_capacity(table.scope_variables.len());
11686 for index in 0..table.scope_variables.len() {
11687 values.push(row.get::<_, Option<String>>(index)?);
11688 }
11689 Ok(values)
11690 });
11691 let Ok(values) = values else {
11692 return false;
11693 };
11694 let mut recorded = false;
11695 for (scope, value) in table.scope_variables.iter().zip(values) {
11696 if let Some(value) = value {
11697 batch.scope(table_name, format!("{}:{value}", scope.prefix));
11698 recorded = true;
11699 }
11700 }
11701 recorded
11702 }
11703
11704 fn record_commit_changes(
11705 &self,
11706 batch: &mut ChangeAccumulator,
11707 tables: &[String],
11708 changes: &[ssp2::model::Change],
11709 ) {
11710 #[cfg(feature = "bench-internals")]
11711 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
11712 for change in changes {
11713 let Some(table_name) = tables.get(change.table_index as usize) else {
11714 continue;
11715 };
11716 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
11717 if let Some(table) = self.schema.table(table_name) {
11718 for (variable, value) in &change.scopes {
11719 if let Some(scope) = table
11720 .scope_variables
11721 .iter()
11722 .find(|scope| &scope.variable == variable)
11723 {
11724 batch.scope(table_name, format!("{}:{value}", scope.prefix));
11725 precise = true;
11726 }
11727 }
11728 }
11729 if !precise {
11730 batch.table(table_name);
11731 }
11732 }
11733 }
11734
11735 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
11736 if effective.is_empty() {
11737 return false;
11738 }
11739 let Some(table) = self.schema.table(table_name) else {
11740 return false;
11741 };
11742 let mut clauses = Vec::new();
11743 let mut params = Vec::new();
11744 for (variable, values) in effective {
11745 let Some(column) = table.scope_column(variable) else {
11746 return false;
11747 };
11748 if values.is_empty() {
11749 return false;
11750 }
11751 let placeholders = values
11752 .iter()
11753 .map(|value| {
11754 params.push(SqlValue::Text(value.clone()));
11755 "?"
11756 })
11757 .collect::<Vec<_>>()
11758 .join(", ");
11759 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
11760 }
11761 let sql = format!(
11762 "SELECT 1 FROM {} WHERE {} LIMIT 1",
11763 base_table(table_name),
11764 clauses.join(" AND ")
11765 );
11766 self.conn
11767 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
11768 .is_ok()
11769 }
11770
11771 pub fn upgrading(&self) -> bool {
11773 self.upgrading
11774 }
11775
11776 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
11784 let new_schema = parse_schema_json(schema_json)?;
11785 validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
11786 self.conn
11787 .execute_batch("BEGIN IMMEDIATE")
11788 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11789 let prior_schema = std::mem::replace(&mut self.schema, new_schema);
11790 let prior_subs = self.subs.clone();
11796 let prior_outbox = self.outbox.clone();
11797 let prior_rejections = self.rejections.clone();
11798 let prior_upgrading = self.upgrading;
11799 let prior_stopped = self.stopped;
11800 let prior_schema_floor = self.schema_floor.clone();
11801 let prior_overlay_dirty = self.overlay_dirty.snapshot();
11802 let prior_sync_needed = self.sync_needed;
11803 let prior_active_round = self.active_round;
11804 let prior_changes = self.change_queue.len();
11805 let prior_intents = self.sync_intent_queue.len();
11806 let prior_last_change = self.last_change.clone();
11807 let guarded = (|| -> Result<(), String> {
11808 let marker = read_local_schema_version(&self.conn, self.schema.version)?;
11813 self.create_bookkeeping_tables()?;
11814 match marker {
11815 Some(version) if version < self.schema.version => self.run_schema_reset()?,
11816 None => {
11817 self.create_synced_tables()?;
11818 self.persist_schema_version()?;
11819 }
11820 _ => {}
11821 }
11822 self.outbox = self.load_outbox()?;
11827 self.drop_unencodable_outbox(false, None)?;
11832 if marker == Some(self.schema.version)
11833 && (!self.outbox.is_empty()
11834 || !self.failed_commits.is_empty()
11835 || self.has_acknowledged_rows())
11836 {
11837 self.overlay_dirty.set(true);
11838 self.rebuild_overlay()?;
11839 }
11840 self.prune_unknown_subscriptions(false)?;
11841 self.save_subscription_scope_schema();
11842 self.enqueue_startup_sync_if_needed()?;
11846 Ok(())
11847 })();
11848 let outcome = match guarded {
11849 Ok(()) => self
11850 .conn
11851 .execute_batch("COMMIT")
11852 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
11853 Err(error) => Err(error),
11854 };
11855 if let Err(error) = outcome {
11856 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
11857 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
11859 }
11860 self.schema = prior_schema;
11861 self.subs = prior_subs;
11862 self.outbox = prior_outbox;
11863 self.rejections = prior_rejections;
11864 self.upgrading = prior_upgrading;
11865 self.stopped = prior_stopped;
11866 self.schema_floor = prior_schema_floor;
11867 self.overlay_dirty.restore(prior_overlay_dirty);
11868 self.sync_needed = prior_sync_needed;
11869 self.active_round = prior_active_round;
11870 self.change_queue.truncate(prior_changes);
11871 self.sync_intent_queue.truncate(prior_intents);
11872 self.last_change = prior_last_change;
11873 self.insert_sql.borrow_mut().clear();
11875 return Err(error);
11876 }
11877 Ok(())
11878 }
11879
11880 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
11881 let startup_work = !self.stopped
11882 && (!self.outbox.is_empty()
11883 || self.has_acknowledged_rows()
11884 || !self.load_pending_evictions()?.is_empty()
11885 || self.subs.iter().any(|sub| sub.state == SubState::Active));
11886 if startup_work {
11887 self.sync_needed = true;
11888 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11889 }
11890 Ok(())
11891 }
11892
11893 fn clear_satisfied_persisted_schema_floor(&mut self) {
11900 let satisfied = self
11901 .schema_floor
11902 .as_ref()
11903 .and_then(|floor| floor.required_schema_version)
11904 .is_some_and(|required| self.schema.version >= required);
11905 if !satisfied {
11906 return;
11907 }
11908 self.schema_floor = None;
11909 self.stopped = false;
11910 self.delete_meta(SCHEMA_FLOOR_KEY);
11911 }
11912
11913 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
11918 let Some(replica_path) = self.previous_version_replica_path() else {
11919 return Ok(());
11920 };
11921 let max_age_ms = self
11922 .previous_version
11923 .filter(|config| config.enabled)
11924 .map(|config| config.max_age_ms);
11925 reconcile_previous_version_at_boot(
11926 &self.conn,
11927 &replica_path,
11928 self.schema.version,
11929 max_age_ms,
11930 self.clock_now_ms(),
11931 )
11932 }
11933
11934 fn previous_version_replica_path(&self) -> Option<String> {
11938 let path = self.conn.path()?;
11939 if path.is_empty() || path.starts_with("file:") {
11940 return None;
11941 }
11942 Some(path.to_owned())
11943 }
11944
11945 fn run_schema_reset(&mut self) -> Result<(), String> {
11951 self.begin_observation("syncular_schema_reset")?;
11952 let prior_subs = self.subs.clone();
11953 let prior_outbox = self.outbox.clone();
11954 let prior_rejections = self.rejections.clone();
11955 let prior_upgrading = self.upgrading;
11956 let prior_stopped = self.stopped;
11957 let prior_schema_floor = self.schema_floor.clone();
11958 let prior_overlay_dirty = self.overlay_dirty.snapshot();
11959 let prior_sync_needed = self.sync_needed;
11960 let prior_active_round = self.active_round;
11961 let mut batch = ChangeAccumulator::default();
11962 let result = self
11963 .run_schema_reset_observed(&mut batch, true)
11964 .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
11965 if let Err(error) = result {
11966 self.rollback_observation("syncular_schema_reset");
11967 self.subs = prior_subs;
11968 self.outbox = prior_outbox;
11969 self.rejections = prior_rejections;
11970 self.upgrading = prior_upgrading;
11971 self.stopped = prior_stopped;
11972 self.schema_floor = prior_schema_floor;
11973 self.overlay_dirty.restore(prior_overlay_dirty);
11974 self.sync_needed = prior_sync_needed;
11975 self.active_round = prior_active_round;
11976 return Err(error);
11977 }
11978 Ok(())
11979 }
11980
11981 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
11982 if self.get_meta(LOG_EPOCH_KEY).is_none() {
11983 self.begin_observation("syncular_epoch_acquisition")?;
11984 let previous_sync_needed = self.sync_needed;
11985 if let Err(error) = self.conn.execute(
11986 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
11987 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
11988 ) {
11989 self.rollback_observation("syncular_epoch_acquisition");
11990 return Err(Self::sqlite_failure(&self.storage_failure, error));
11991 }
11992 self.sync_needed = true;
11993 let batch = ChangeAccumulator {
11994 status: true,
11995 ..ChangeAccumulator::default()
11996 };
11997 if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
11998 self.rollback_observation("syncular_epoch_acquisition");
11999 self.sync_needed = previous_sync_needed;
12000 return Err(error);
12001 }
12002 self.sync_intent_queue.push_back(SyncIntent::Interactive);
12003 return Ok(Vec::new());
12004 }
12005 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
12006 self.begin_observation("syncular_log_epoch_reset")?;
12007 let prior_subs = self.subs.clone();
12008 let prior_outbox = self.outbox.clone();
12009 let prior_rejections = self.rejections.clone();
12010 let prior_upgrading = self.upgrading;
12011 let prior_stopped = self.stopped;
12012 let prior_schema_floor = self.schema_floor.clone();
12013 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12014 let prior_sync_needed = self.sync_needed;
12015 let prior_active_round = self.active_round;
12016 let mut batch = ChangeAccumulator::default();
12017 let result = self
12018 .run_schema_reset_observed(&mut batch, false)
12019 .and_then(|()| {
12020 self.conn
12021 .execute(
12022 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12023 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
12024 )
12025 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12026 self.sync_needed = true;
12027 batch.status = true;
12028 self.finish_observation("syncular_log_epoch_reset", batch)
12029 });
12030 if let Err(error) = result {
12031 self.rollback_observation("syncular_log_epoch_reset");
12032 self.subs = prior_subs;
12033 self.outbox = prior_outbox;
12034 self.rejections = prior_rejections;
12035 self.upgrading = prior_upgrading;
12036 self.stopped = prior_stopped;
12037 self.schema_floor = prior_schema_floor;
12038 self.overlay_dirty.restore(prior_overlay_dirty);
12039 self.sync_needed = prior_sync_needed;
12040 self.active_round = prior_active_round;
12041 return Err(error);
12042 }
12043 self.sync_intent_queue.push_back(SyncIntent::Interactive);
12044 Ok(resets)
12045 }
12046
12047 fn run_schema_reset_observed(
12048 &mut self,
12049 batch: &mut ChangeAccumulator,
12050 capture: bool,
12051 ) -> Result<(), String> {
12052 read_local_schema_version(&self.conn, self.schema.version)?;
12058 self.conn
12059 .execute_batch(
12060 "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
12061 )
12062 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12063 self.cancel_sync_round();
12064 self.upgrading = true;
12065 batch.status = true;
12066 if self.previous_version.is_some_and(|config| config.enabled) {
12074 let previous_version = self
12075 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
12076 .and_then(|value| value.parse::<i32>().ok())
12077 .unwrap_or(self.schema.version);
12078 let pending = self
12079 .outbox
12080 .iter()
12081 .map(|commit| PendingCommitAudit {
12082 commit_id: &commit.client_commit_id,
12083 operations: commit_audit_operations(commit),
12084 })
12085 .collect::<Vec<_>>();
12086 crate::previous_version::write_previous_version_audit(
12087 &self.conn,
12088 &crate::previous_version::build_previous_version_audit(
12089 &self.schema,
12090 &pending,
12091 previous_version,
12092 self.schema.version,
12093 self.clock_now_ms(),
12094 ),
12095 );
12096 }
12097 if let Some(replica_path) = self.previous_version_replica_path() {
12102 sweep_previous_version_container(&self.conn, &replica_path)?;
12103 if capture {
12104 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
12105 let previous_version = self
12106 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
12107 .and_then(|value| value.parse::<i32>().ok())
12108 .unwrap_or(self.schema.version);
12109 capture_previous_version_from_replica(
12110 &self.conn,
12111 &replica_path,
12112 previous_version,
12113 self.schema.version,
12114 &config,
12115 self.clock_now_ms(),
12116 )?;
12117 }
12118 }
12119 }
12120 for table in &self.schema.tables {
12121 batch.table(&table.name);
12122 }
12123 for (base_key, unit, table) in self.load_registered_window_units() {
12124 batch.window(&base_key, &table, &unit);
12125 }
12126 if capture {
12127 self.prune_unknown_subscriptions(true)?;
12128 }
12129 self.insert_sql.borrow_mut().clear();
12131 self.overlay_dirty.set(true);
12132 let virtual_tables: Vec<String> = {
12140 let mut stmt = self
12141 .conn
12142 .prepare(
12143 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
12144 )
12145 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12146 let rows = stmt
12147 .query_map([], |row| row.get::<_, String>(0))
12148 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12149 rows.filter_map(Result::ok)
12150 .filter(|name| is_synced_table_name(name))
12151 .collect()
12152 };
12153 for name in virtual_tables {
12154 self.conn
12155 .execute_batch(&format!(
12156 "DROP TABLE IF EXISTS {}",
12157 quote_ident(&format!("_syncular_fts_{name}"))
12158 ))
12159 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12160 self.conn
12161 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
12162 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12163 }
12164 let existing: Vec<String> = {
12165 let mut stmt = self
12166 .conn
12167 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
12168 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12169 let rows = stmt
12170 .query_map([], |row| row.get::<_, String>(0))
12171 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12172 rows.filter_map(Result::ok)
12173 .filter(|name| is_synced_table_name(name))
12174 .collect()
12175 };
12176 for name in &existing {
12177 if let Some(table) = name.strip_prefix("_syncular_base_") {
12178 batch.table(table);
12179 } else if !name.starts_with("_syncular_") {
12180 batch.table(name);
12181 }
12182 }
12183 for name in existing {
12184 self.conn
12185 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
12186 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12187 }
12188 self.create_synced_tables()?;
12190 for sub in &mut self.subs {
12192 sub.cursor = -1;
12193 sub.bootstrap_state = None;
12194 sub.effective = None;
12195 sub.state = SubState::Active;
12196 sub.reason_code = None;
12197 sub.synced_once = false;
12198 }
12199 let subs = self.subs.clone();
12200 for sub in &subs {
12201 self.persist_sub(sub)?;
12202 }
12203 self.stopped = false;
12205 self.schema_floor = None;
12206 self.delete_meta(SCHEMA_FLOOR_KEY);
12207 self.persist_schema_version()?;
12209 self.save_subscription_scope_schema();
12210 self.drop_unencodable_outbox(false, Some(batch))?;
12221 self.rebuild_overlay()?;
12223 Ok(())
12224 }
12225
12226 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
12233 let incompatible = self
12234 .outbox
12235 .iter()
12236 .filter(|commit| {
12237 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
12238 })
12239 .cloned()
12240 .collect::<Vec<_>>();
12241 if incompatible.is_empty() {
12242 return Ok(false);
12243 }
12244 let prior_outbox = self.outbox.clone();
12250 let prior_rejections = self.rejections.clone();
12251 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12252 let owns_transaction = self.conn.is_autocommit();
12253 self.begin_observation("syncular_outbox_drop")?;
12254 let mut batch = ChangeAccumulator::default();
12255 let dropped = (|| -> Result<(), String> {
12256 let mut rejections = Vec::new();
12257 for commit in &incompatible {
12258 let results = commit
12259 .ops
12260 .iter()
12261 .enumerate()
12262 .map(|(op_index, operation)| {
12263 let rejection = RejectionRecord {
12264 client_commit_id: commit.client_commit_id.clone(),
12265 op_index: op_index as i32,
12266 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
12267 message: "the persisted commit cannot encode under the current schema"
12268 .to_owned(),
12269 retryable: false,
12270 details: None,
12271 operation: Some(CommitOperation::from(operation)),
12272 };
12273 rejections.push(rejection.clone());
12274 CommitOperationOutcome::Error { rejection }
12275 })
12276 .collect::<Vec<_>>();
12277 self.persist_commit_outcome(
12278 &commit.client_commit_id,
12279 CommitOutcomeStatus::Rejected,
12280 &results,
12281 Some(&commit.ops),
12282 )?;
12283 self.delete_outbox_persisted(&commit.client_commit_id)?;
12284 for operation in &commit.ops {
12287 if self.schema.table(&operation.table).is_some() {
12288 batch.table(&operation.table);
12289 }
12290 }
12291 }
12292 self.prune_commit_outcomes()?;
12293 let incompatible_ids = incompatible
12294 .iter()
12295 .map(|commit| commit.client_commit_id.as_str())
12296 .collect::<BTreeSet<_>>();
12297 self.outbox
12298 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
12299 self.rejections.extend(rejections);
12300 self.overlay_dirty.set(true);
12303 self.rebuild_overlay()?;
12304 batch.status = true;
12305 batch.rejections = true;
12306 batch.outcomes = true;
12307 Ok(())
12308 })();
12309 let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
12310 if let Err(error) = result {
12311 if owns_transaction {
12315 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12316 let mut failure = self.storage_failure.borrow_mut();
12317 let failure =
12318 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
12319 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
12320 }
12321 } else {
12322 self.rollback_observation("syncular_outbox_drop");
12323 }
12324 self.outbox = prior_outbox;
12325 self.rejections = prior_rejections;
12326 self.overlay_dirty.restore(prior_overlay_dirty);
12327 return Err(error);
12328 }
12329 Ok(true)
12330 }
12331
12332 fn create_synced_tables(&self) -> Result<(), String> {
12335 for table in &self.schema.tables {
12336 for (full, index_prefix) in [
12340 (base_table(&table.name), "_syncular_base_"),
12341 (visible_table(&table.name), ""),
12342 ] {
12343 let mut cols: Vec<String> =
12344 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
12345 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
12346 let sql = format!(
12347 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
12348 cols.join(", "),
12349 quote_ident(&table.primary_key)
12350 );
12351 self.conn
12352 .execute(&sql, [])
12353 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12354 for index in &table.indexes {
12358 let unique = if index.unique { "UNIQUE " } else { "" };
12359 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
12360 let cols_sql = index
12361 .columns
12362 .iter()
12363 .map(|c| quote_ident(c))
12364 .collect::<Vec<_>>()
12365 .join(", ");
12366 let index_sql = format!(
12367 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
12368 );
12369 self.conn
12370 .execute(&index_sql, [])
12371 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12372 }
12373 }
12374 }
12375 for table in &self.schema.tables {
12378 for index in &table.fts_indexes {
12379 self.create_fts_projection(table, index)?;
12380 }
12381 }
12382 Ok(())
12383 }
12384
12385 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
12386 let count: i64 = self
12387 .conn
12388 .query_row(
12389 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
12390 rusqlite::params![index.name],
12391 |row| row.get(0),
12392 )
12393 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12394 Ok(count > 0)
12395 }
12396
12397 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
12398 for suffix in ["ai", "ad", "au"] {
12399 self.conn
12400 .execute(
12401 &format!(
12402 "DROP TRIGGER IF EXISTS {}",
12403 quote_ident(&format!("{}_{suffix}", index.name))
12404 ),
12405 [],
12406 )
12407 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12408 }
12409 Ok(())
12410 }
12411
12412 fn create_fts_triggers(
12413 &self,
12414 table: &TableSchema,
12415 index: &FtsIndexSchema,
12416 ) -> Result<(), String> {
12417 let fts = quote_ident(&index.name);
12418 let source = visible_table(&table.name);
12419 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
12420 let pk = quote_ident(&table.primary_key);
12421 let projection_columns = std::iter::once(source_id.clone())
12422 .chain(index.columns.iter().map(|column| quote_ident(column)))
12423 .collect::<Vec<_>>()
12424 .join(", ");
12425 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
12426 .chain(
12427 index
12428 .columns
12429 .iter()
12430 .map(|column| format!("new.{}", quote_ident(column))),
12431 )
12432 .collect::<Vec<_>>()
12433 .join(", ");
12434 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12435 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)");
12436 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)");
12437 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})");
12438 let bi = quote_ident(&format!("{}_bi", index.name));
12439 let ai = quote_ident(&format!("{}_ai", index.name));
12440 let ad = quote_ident(&format!("{}_ad", index.name));
12441 let au = quote_ident(&format!("{}_au", index.name));
12442 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
12443 let sql = format!(
12444 "DROP TRIGGER IF EXISTS {bi};
12445 DROP TRIGGER IF EXISTS {ai};
12446 DROP TRIGGER IF EXISTS {ad};
12447 DROP TRIGGER IF EXISTS {au};
12448 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
12449 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
12450 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
12451 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
12452 );
12453 self.conn
12454 .execute_batch(&sql)
12455 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12456 }
12457
12458 fn rebuild_fts_projection(
12459 &self,
12460 table: &TableSchema,
12461 index: &FtsIndexSchema,
12462 ) -> Result<(), String> {
12463 let fts = quote_ident(&index.name);
12464 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
12465 let pk = quote_ident(&table.primary_key);
12466 let indexed_columns = index
12467 .columns
12468 .iter()
12469 .map(|column| quote_ident(column))
12470 .collect::<Vec<_>>();
12471 let projection_columns = std::iter::once(source_id.clone())
12472 .chain(indexed_columns.iter().cloned())
12473 .collect::<Vec<_>>()
12474 .join(", ");
12475 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12476 let sql = format!(
12477 "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};",
12478 columns = indexed_columns.join(", "),
12479 source = visible_table(&table.name),
12480 );
12481 self.conn
12482 .execute_batch(&sql)
12483 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12484 }
12485
12486 fn create_fts_projection(
12487 &self,
12488 table: &TableSchema,
12489 index: &FtsIndexSchema,
12490 ) -> Result<(), String> {
12491 self.begin_observation("syncular_fts_schema")?;
12492 let result = (|| {
12493 let existed = self.fts_projection_exists(index)?;
12494 let tokenizer = index.tokenize.replace('\'', "''");
12495 let columns = index
12496 .columns
12497 .iter()
12498 .map(|column| quote_ident(column))
12499 .collect::<Vec<_>>()
12500 .join(", ");
12501 let sql = format!(
12502 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
12503 fts = quote_ident(&index.name),
12504 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
12505 );
12506 self.conn.execute(&sql, []).map_err(|error| {
12507 format!(
12508 "cannot create local FTS5 projection {:?}: {error}",
12509 index.name
12510 )
12511 })?;
12512 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12513 let mapped: bool = self
12514 .conn
12515 .query_row(
12516 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
12517 [format!("_syncular_fts_{}", index.name)],
12518 |row| row.get(0),
12519 )
12520 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12521 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))?;
12522 if !mapped && existed {
12523 self.conn
12524 .execute_batch(&format!(
12525 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
12526 quote_ident(FTS_SOURCE_ID_COLUMN),
12527 quote_ident(&index.name)
12528 ))
12529 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12530 }
12531 self.create_fts_triggers(table, index)?;
12532 if !existed {
12533 self.rebuild_fts_projection(table, index)?;
12534 }
12535 Ok(())
12536 })();
12537 if let Err(error) = result {
12538 self.rollback_observation("syncular_fts_schema");
12539 return Err(error);
12540 }
12541 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
12542 self.rollback_observation("syncular_fts_schema");
12543 return Err(error.to_string());
12544 }
12545 Ok(())
12546 }
12547
12548 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
12551 let state = serde_json::json!({
12552 "requested": scope_map_to_json(&sub.requested),
12553 "params": sub.params,
12554 "cursor": sub.cursor,
12555 "bootstrapState": sub.bootstrap_state,
12556 "status": sub.state.name(),
12557 "reasonCode": sub.reason_code,
12558 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
12559 "syncedOnce": sub.synced_once,
12560 });
12561 self.conn.execute(
12562 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
12563 rusqlite::params![sub.id, sub.table, state.to_string()],
12564 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12565 }
12566
12567 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
12568 let ops: Vec<Value> = commit
12569 .ops
12570 .iter()
12571 .map(|op| {
12572 serde_json::json!({
12573 "op": if op.upsert { "upsert" } else { "delete" },
12574 "table": op.table,
12575 "rowId": op.row_id,
12576 "baseVersion": op.base_version,
12577 "values": op.values.clone().map(Value::Object),
12578 })
12579 })
12580 .collect();
12581 self.conn
12582 .execute(
12583 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
12584 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
12585 )
12586 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12587 for operation in &commit.ops {
12588 let Some(values) = &operation.values else {
12589 continue;
12590 };
12591 let Some(table) = self.schema.table(&operation.table) else {
12592 continue;
12593 };
12594 for column in table
12595 .columns
12596 .iter()
12597 .filter(|column| column.ty == ColumnType::BlobRef)
12598 {
12599 let Some(Value::String(raw)) = values.get(&column.name) else {
12600 continue;
12601 };
12602 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
12603 continue;
12604 };
12605 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
12606 continue;
12607 };
12608 self.conn
12609 .execute(
12610 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
12611 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
12612 rusqlite::params![commit.client_commit_id, blob_id],
12613 )
12614 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12615 }
12616 }
12617 Ok(())
12618 }
12619
12620 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
12621 if let Some(commit) = self
12622 .outbox
12623 .iter()
12624 .find(|commit| commit.client_commit_id == client_commit_id)
12625 {
12626 for operation in &commit.ops {
12627 self.overlay_dirty.table(&operation.table);
12628 }
12629 }
12630
12631 self.conn
12632 .execute(
12633 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
12634 rusqlite::params![client_commit_id],
12635 )
12636 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12637 self.conn
12638 .execute(
12639 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
12640 rusqlite::params![client_commit_id],
12641 )
12642 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12643 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)", [])
12644 .map(|_| ())
12645 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12646 }
12647
12648 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
12649 match status {
12650 CommitOutcomeStatus::Applied => "applied",
12651 CommitOutcomeStatus::Cached => "cached",
12652 CommitOutcomeStatus::Conflict => "conflict",
12653 CommitOutcomeStatus::Rejected => "rejected",
12654 }
12655 }
12656
12657 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
12658 match resolution {
12659 CommitOutcomeResolution::Active => "active",
12660 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
12661 CommitOutcomeResolution::Superseded => "superseded",
12662 CommitOutcomeResolution::Dismissed => "dismissed",
12663 }
12664 }
12665
12666 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
12667 match value {
12668 "applied" => Ok(CommitOutcomeStatus::Applied),
12669 "cached" => Ok(CommitOutcomeStatus::Cached),
12670 "conflict" => Ok(CommitOutcomeStatus::Conflict),
12671 "rejected" => Ok(CommitOutcomeStatus::Rejected),
12672 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
12673 }
12674 }
12675
12676 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
12677 match value {
12678 "active" => Ok(CommitOutcomeResolution::Active),
12679 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
12680 "superseded" => Ok(CommitOutcomeResolution::Superseded),
12681 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
12682 _ => Err(format!(
12683 "invalid persisted commit outcome resolution {value:?}"
12684 )),
12685 }
12686 }
12687
12688 fn persist_commit_outcome(
12689 &self,
12690 client_commit_id: &str,
12691 status: CommitOutcomeStatus,
12692 results: &[CommitOperationOutcome],
12693 operations: Option<&[OutboxOp]>,
12694 ) -> Result<(), String> {
12695 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
12696 let operations_json = operations
12697 .map(|items| {
12698 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
12699 })
12700 .transpose()
12701 .map_err(|error| error.to_string())?;
12702 self.conn
12703 .execute(
12704 "INSERT INTO _syncular_commit_outcomes (
12705 client_commit_id, status, recorded_at_ms, results_json,
12706 operations_json, resolution
12707 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
12708 rusqlite::params![
12709 client_commit_id,
12710 Self::outcome_status_name(status),
12711 self.clock_now_ms(),
12712 results_json,
12713 operations_json
12714 ],
12715 )
12716 .map(|_| ())
12717 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12718 }
12719
12720 fn stored_cell_is_representable(cell: &Value) -> bool {
12723 cell.is_null()
12724 || cell.is_string()
12725 || cell.is_boolean()
12726 || cell.is_number()
12727 || is_canonical_bytes_envelope(cell)
12728 }
12729
12730 fn stored_row_is_representable(value: &Value) -> bool {
12731 value
12732 .as_object()
12733 .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
12734 }
12735
12736 fn stored_operation_is_representable(value: &Value) -> bool {
12737 value.get("table").is_some_and(Value::is_string)
12738 && value.get("rowId").is_some_and(Value::is_string)
12739 && value.get("baseVersion").is_none_or(|v| {
12740 v.as_i64()
12741 .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
12742 })
12743 && match value.get("op").and_then(Value::as_str) {
12744 Some("upsert") => value
12745 .get("values")
12746 .is_some_and(Self::stored_row_is_representable),
12747 Some("delete") => value.get("values").is_none(),
12748 _ => false,
12749 }
12750 }
12751
12752 fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
12756 serde_json::to_value(CommitOperation::from(operation))
12757 .is_ok_and(|value| Self::stored_operation_is_representable(&value))
12758 }
12759
12760 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
12761 let StoredCommitOutcomeRow {
12762 sequence,
12763 client_commit_id,
12764 status,
12765 recorded_at_ms,
12766 results_json,
12767 operations_json,
12768 resolution,
12769 resolved_at_ms,
12770 replacement_client_commit_id,
12771 } = row;
12772 if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
12773 || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
12774 || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
12775 {
12776 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
12777 }
12778 let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
12779 let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
12780 for result in &stored {
12781 let nested = match result.get("status").and_then(Value::as_str) {
12782 Some("conflict") => result.get("conflict"),
12783 Some("error") => result.get("rejection"),
12784 _ => None,
12785 };
12786 if nested
12787 .and_then(|record| record.get("operation"))
12788 .is_some_and(|op| !Self::stored_operation_is_representable(op))
12789 || (result.get("status").and_then(Value::as_str) == Some("conflict")
12790 && nested
12791 .and_then(|record| record.get("serverRow"))
12792 .is_some_and(|row| !Self::stored_row_is_representable(row)))
12793 {
12794 return Err(invalid());
12795 }
12796 }
12797 let results: Vec<CommitOperationOutcome> =
12798 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
12799 let has_conflict = results
12800 .iter()
12801 .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
12802 let has_error = results
12803 .iter()
12804 .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
12805 let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
12806 let redacted = results.is_empty() && operations_json.is_none();
12807 if match status {
12808 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
12809 CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
12810 CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
12811 } {
12812 return Err(invalid());
12813 }
12814 let operations: Option<Vec<CommitOperation>> = operations_json
12815 .map(|raw| {
12816 let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
12817 if !stored.iter().all(Self::stored_operation_is_representable) {
12818 return Err(invalid());
12819 }
12820 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
12821 })
12822 .transpose()?;
12823 for result in &results {
12824 match result {
12825 CommitOperationOutcome::Conflict { conflict }
12826 if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
12827 {
12828 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
12829 }
12830 CommitOperationOutcome::Error { rejection } => {
12831 if let Some(details) = &rejection.details {
12832 let encoded = serde_json::to_string(details).map_err(|_| {
12833 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
12834 })?;
12835 RejectionDetails::parse(&encoded).map_err(|_| {
12836 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
12837 })?;
12838 }
12839 }
12840 _ => {}
12841 }
12842 }
12843 Ok(CommitOutcome {
12844 sequence,
12845 client_commit_id,
12846 status,
12847 recorded_at_ms,
12848 results,
12849 operations,
12850 retained_rows: None,
12851 resolution: Self::parse_outcome_resolution(&resolution)?,
12852 resolved_at_ms,
12853 replacement_client_commit_id,
12854 })
12855 }
12856
12857 fn unique_conflicts(
12859 &self,
12860 table: &TableSchema,
12861 values: &Map<String, Value>,
12862 base: bool,
12863 ) -> Result<Vec<RetainedUniqueConflict>, String> {
12864 let source = if base {
12865 base_table(&table.name)
12866 } else {
12867 visible_table(&table.name)
12868 };
12869 let mut conflicts = Vec::new();
12870 for index in table.indexes.iter().filter(|index| index.unique) {
12871 let mut params = vec![json_param_to_sql(
12872 values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
12873 )?];
12874 for name in &index.columns {
12875 let column = table
12876 .columns
12877 .iter()
12878 .find(|column| column.name == *name)
12879 .ok_or("sync.local_corrupt")?;
12880 let cell = json_to_column_value(column, values.get(name))?;
12881 params.push(owned_sql_value(RowParam::Cell(&cell))?);
12882 }
12883 let sql = format!(
12884 "SELECT * FROM {source} WHERE {} != ? AND {}",
12885 quote_ident(&table.primary_key),
12886 index
12887 .columns
12888 .iter()
12889 .map(|column| format!("{} = ?", quote_ident(column)))
12890 .collect::<Vec<_>>()
12891 .join(" AND ")
12892 );
12893 let mut statement = self
12894 .conn
12895 .prepare_cached(&sql)
12896 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12897 let records = statement
12898 .query_map(rusqlite::params_from_iter(params), |record| {
12899 let values = table
12900 .columns
12901 .iter()
12902 .enumerate()
12903 .map(|(index, column)| {
12904 let value = sql_ref_to_json(column, record.get_ref(index)?);
12905 json_to_column_value(column, Some(&value)).map_err(|message| {
12906 rusqlite::Error::ToSqlConversionFailure(message.into())
12907 })
12908 })
12909 .collect::<Result<Row, _>>()?;
12910 Ok((values, record.get::<_, i64>(table.columns.len())?))
12911 })
12912 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12913 for record in records {
12914 let (row, version) =
12915 record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12916 let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
12917 conflicts.push(RetainedUniqueConflict {
12918 index: index.name.clone(),
12919 columns: index.columns.clone(),
12920 row_id,
12921 server_row: table
12922 .columns
12923 .iter()
12924 .zip(&row)
12925 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
12926 .collect(),
12927 server_version: version,
12928 });
12929 }
12930 }
12931 Ok(conflicts)
12932 }
12933
12934 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
12935 if let Some((commit, initial)) = self
12936 .failed_commits
12937 .iter()
12938 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
12939 {
12940 let mut rows = Vec::new();
12941 for operation in &commit.ops {
12942 let table = self
12943 .schema
12944 .table(&operation.table)
12945 .ok_or_else(|| "sync.unknown_table".to_owned())?;
12946 let key =
12947 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
12948 .map_err(|_| "sync.local_corrupt".to_owned())?;
12949 let base = self.stored_row(table, &operation.row_id, true)?;
12950 rows.push(RetainedCommitRow {
12951 table: operation.table.clone(),
12952 row_id: operation.row_id.clone(),
12953 unique_conflicts: if operation.upsert {
12954 self.unique_conflicts(
12955 table,
12956 initial
12957 .get(&key)
12958 .and_then(Value::as_object)
12959 .or(operation.values.as_ref())
12960 .ok_or("sync.local_corrupt")?,
12961 true,
12962 )?
12963 } else {
12964 Vec::new()
12965 },
12966 local_row: if operation.upsert {
12967 initial
12968 .get(&key)
12969 .and_then(Value::as_object)
12970 .cloned()
12971 .or_else(|| operation.values.clone())
12972 } else {
12973 None
12974 },
12975 server_row: base.as_ref().map(|(values, _)| {
12976 table
12977 .columns
12978 .iter()
12979 .zip(values)
12980 .map(|(column, value)| {
12981 (column.name.clone(), column_value_to_json(value))
12982 })
12983 .collect()
12984 }),
12985 server_version: base.map(|(_, version)| version),
12986 });
12987 }
12988 outcome.retained_rows = Some(rows);
12989 }
12990 Ok(outcome)
12991 }
12992
12993 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
12994 let row = self
12995 .conn
12996 .query_row(
12997 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
12998 resolution, resolved_at_ms, replacement_client_commit_id
12999 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
13000 rusqlite::params![client_commit_id],
13001 |row| {
13002 Ok(StoredCommitOutcomeRow {
13003 sequence: row.get(0)?,
13004 client_commit_id: row.get(1)?,
13005 status: row.get(2)?,
13006 recorded_at_ms: row.get(3)?,
13007 results_json: row.get(4)?,
13008 operations_json: row.get(5)?,
13009 resolution: row.get(6)?,
13010 resolved_at_ms: row.get(7)?,
13011 replacement_client_commit_id: row.get(8)?,
13012 })
13013 },
13014 )
13015 .optional()
13016 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13017 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
13018 .transpose()
13019 }
13020
13021 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
13022 if query.limit == Some(0) {
13023 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
13024 }
13025 let mut sql = String::from(
13026 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
13027 resolution, resolved_at_ms, replacement_client_commit_id
13028 FROM _syncular_commit_outcomes",
13029 );
13030 if query.active_only {
13031 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
13032 }
13033 sql.push_str(" ORDER BY seq DESC");
13034 if let Some(limit) = query.limit {
13035 sql.push_str(&format!(" LIMIT {limit}"));
13036 }
13037 let mut stmt = self
13038 .conn
13039 .prepare(&sql)
13040 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13041 let rows = stmt
13042 .query_map([], |row| {
13043 Ok(StoredCommitOutcomeRow {
13044 sequence: row.get(0)?,
13045 client_commit_id: row.get(1)?,
13046 status: row.get(2)?,
13047 recorded_at_ms: row.get(3)?,
13048 results_json: row.get(4)?,
13049 operations_json: row.get(5)?,
13050 resolution: row.get(6)?,
13051 resolved_at_ms: row.get(7)?,
13052 replacement_client_commit_id: row.get(8)?,
13053 })
13054 })
13055 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13056 let mut outcomes = Vec::new();
13057 for row in rows {
13058 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
13059 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
13060 )?)?);
13061 }
13062 Ok(outcomes)
13063 }
13064
13065 fn prune_commit_outcomes(&self) -> Result<(), String> {
13066 #[cfg(test)]
13067 self.outcome_prune_count
13068 .set(self.outcome_prune_count.get() + 1);
13069 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
13070 let count = self
13071 .conn
13072 .query_row(
13073 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
13074 [],
13075 |row| row.get::<_, i64>(0),
13076 )
13077 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13078 as usize;
13079 let excess = count.saturating_sub(max_entries);
13080 if excess == 0 {
13081 return Ok(());
13082 }
13083 let mut stmt = self
13084 .conn
13085 .prepare(
13086 "SELECT seq FROM _syncular_commit_outcomes
13087 WHERE status IN ('applied', 'cached') OR resolution != 'active'
13088 ORDER BY seq ASC LIMIT ?1",
13089 )
13090 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13091 let rows = stmt
13092 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
13093 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13094 let sequences = rows
13095 .collect::<Result<Vec<_>, _>>()
13096 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13097 drop(stmt);
13098 for sequence in sequences {
13099 self.conn
13100 .execute(
13101 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
13102 rusqlite::params![sequence],
13103 )
13104 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13105 }
13106 Ok(())
13107 }
13108
13109 pub fn subscribe(
13112 &mut self,
13113 id: String,
13114 table: String,
13115 scopes: Vec<(String, Vec<String>)>,
13116 params: Option<String>,
13117 ) -> Result<(), String> {
13118 if self.schema.table(&table).is_none() {
13119 return Err(format!("unknown table {table:?}"));
13120 }
13121 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
13122 let same_intent = existing.table == table
13123 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
13124 && existing.params == params;
13125 if same_intent {
13126 return Ok(());
13127 }
13128 return Err(
13129 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
13130 .to_owned(),
13131 );
13132 }
13133 let sub = Subscription {
13134 id: id.clone(),
13135 table,
13136 requested: scopes,
13137 params,
13138 cursor: -1,
13139 bootstrap_state: None,
13140 state: SubState::Active,
13141 reason_code: None,
13142 effective: None,
13143 synced_once: false,
13144 };
13145 self.persist_sub(&sub)?;
13146 self.subs.push(sub);
13147 Ok(())
13148 }
13149
13150 pub fn unsubscribe(&mut self, id: &str) {
13151 if self.subs.iter().any(|s| s.id == id) {
13152 self.cancel_sync_round();
13153 }
13154 self.subs.retain(|s| s.id != id);
13155 let _ = self.conn.execute(
13156 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
13157 rusqlite::params![id],
13158 );
13159 }
13160
13161 pub fn set_window(
13169 &mut self,
13170 base: &WindowBase,
13171 units: &[String],
13172 ) -> Result<CommandEffects, String> {
13173 let table = self
13174 .schema
13175 .table(&base.table)
13176 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
13177 if table.scope_column(&base.variable).is_none() {
13178 return Err(format!(
13179 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
13180 base.table, base.variable
13181 ));
13182 }
13183 let base_key = window_base_key(base);
13184 let wanted: std::collections::HashSet<&String> = units.iter().collect();
13185 let live = self.load_window_units(&base_key);
13186 self.begin_observation("syncular_window")?;
13187 let mut batch = ChangeAccumulator::default();
13188 let mut changed = false;
13189
13190 let prior_subs = self.subs.clone();
13191 let widened = (|| {
13192 for unit in units {
13194 if live.iter().any(|(u, _)| u == unit) {
13195 continue;
13196 }
13197 let sub_id = derive_sub_id(base, unit);
13198 self.delete_pending_evict(&sub_id)?;
13199 self.insert_window_unit(&base_key, unit, &sub_id)?;
13200 self.subscribe(
13201 sub_id,
13202 base.table.clone(),
13203 unit_scopes(base, unit),
13204 base.params.clone(),
13205 )?;
13206 batch.window(&base_key, &base.table, unit);
13207 changed = true;
13208 }
13209
13210 self.finish_observation("syncular_window", batch)
13211 })();
13212 if let Err(error) = widened {
13213 self.rollback_observation("syncular_window");
13214 self.subs = prior_subs;
13215 return Err(error);
13216 }
13217 for (unit, sub_id) in live {
13218 if wanted.contains(&unit) {
13219 continue;
13220 }
13221 self.rebuild_overlay_if_dirty()?;
13222 let prior_subs = self.subs.clone();
13223 self.begin_observation("syncular_window")?;
13224 let effective = self
13225 .subs
13226 .iter()
13227 .find(|sub| sub.id == sub_id)
13228 .and_then(|sub| sub.effective.clone())
13229 .unwrap_or_else(|| unit_scopes(base, &unit));
13230 let mut batch = ChangeAccumulator::default();
13231 self.record_scope_map(&mut batch, &base.table, &effective);
13232 batch.window(&base_key, &base.table, &unit);
13233 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
13234 let remaining = match result {
13235 Ok(remaining) => remaining,
13236 Err(error) => {
13237 self.rollback_observation("syncular_window");
13238 self.subs = prior_subs;
13239 return Err(error);
13240 }
13241 };
13242 if let Err(error) = self.finish_observation("syncular_window", batch) {
13243 self.rollback_observation("syncular_window");
13244 self.subs = prior_subs;
13245 return Err(error);
13246 }
13247 if remaining {
13248 self.drain_pending_evictions()?;
13249 }
13250 changed = true;
13251 }
13252 Ok(if changed {
13253 CommandEffects::interactive()
13254 } else {
13255 CommandEffects::none()
13256 })
13257 }
13258
13259 pub fn window_state(&self, base: &WindowBase) -> WindowState {
13264 let mut units = Vec::new();
13265 let mut pending = Vec::new();
13266 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
13267 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
13268 Some(sub) => {
13269 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
13270 }
13271 None => true,
13272 };
13273 if is_pending {
13274 pending.push(unit.clone());
13275 }
13276 units.push(unit);
13277 }
13278 WindowState { units, pending }
13279 }
13280
13281 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
13282 let mut stmt = match self
13283 .conn
13284 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
13285 {
13286 Ok(stmt) => stmt,
13287 Err(_) => return Vec::new(),
13288 };
13289 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
13290 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
13291 });
13292 match rows {
13293 Ok(rows) => rows.filter_map(Result::ok).collect(),
13294 Err(_) => Vec::new(),
13295 }
13296 }
13297
13298 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
13299 let mut stmt = match self.conn.prepare(
13300 "SELECT windows.base, windows.unit, subscriptions.tbl
13301 FROM _syncular_windows AS windows
13302 JOIN _syncular_subscriptions AS subscriptions
13303 ON subscriptions.id = windows.sub_id
13304 ORDER BY windows.base, windows.unit",
13305 ) {
13306 Ok(stmt) => stmt,
13307 Err(_) => return Vec::new(),
13308 };
13309 let rows = stmt.query_map([], |row| {
13310 Ok((
13311 row.get::<_, String>(0)?,
13312 row.get::<_, String>(1)?,
13313 row.get::<_, String>(2)?,
13314 ))
13315 });
13316 match rows {
13317 Ok(rows) => rows.filter_map(Result::ok).collect(),
13318 Err(_) => Vec::new(),
13319 }
13320 }
13321
13322 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
13323 self.conn
13324 .query_row(
13325 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
13326 rusqlite::params![sub_id],
13327 |row| Ok((row.get(0)?, row.get(1)?)),
13328 )
13329 .ok()
13330 }
13331
13332 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
13333 self.conn
13334 .execute(
13335 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
13336 rusqlite::params![base_key, unit, sub_id],
13337 )
13338 .map(|_| ())
13339 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13340 }
13341
13342 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
13343 self.conn
13344 .execute(
13345 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
13346 rusqlite::params![base_key, unit],
13347 )
13348 .map(|_| ())
13349 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13350 }
13351
13352 fn evict_unit(
13359 &mut self,
13360 base_key: &str,
13361 base: &WindowBase,
13362 unit: &str,
13363 sub_id: &str,
13364 ) -> Result<bool, String> {
13365 let effective = self
13366 .subs
13367 .iter()
13368 .find(|s| s.id == sub_id)
13369 .and_then(|s| s.effective.clone())
13370 .unwrap_or_else(|| unit_scopes(base, unit));
13371 let pinned = self.pinned_row_ids(&base.table);
13372 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
13373 self.delete_window_unit(base_key, unit)?;
13374 self.conn
13375 .execute(
13376 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
13377 [sub_id],
13378 )
13379 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13380 self.subs.retain(|sub| sub.id != sub_id);
13381 if deferred {
13382 self.save_pending_evict(sub_id, &base.table, &effective)?;
13383 } else {
13384 self.delete_pending_evict(sub_id)?;
13385 }
13386 Ok(remaining)
13387 }
13388
13389 fn evict_scope_rows(
13393 &self,
13394 table_name: &str,
13395 effective: &[(String, Vec<String>)],
13396 pinned: &std::collections::HashSet<String>,
13397 ) -> Result<(bool, bool), String> {
13398 if effective.is_empty() {
13399 return Ok((false, false));
13400 }
13401 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
13402 let mut clauses = Vec::new();
13403 let mut params: Vec<SqlValue> = Vec::new();
13404 for (variable, values) in effective {
13405 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
13406 if values.is_empty() {
13407 return Ok((false, false));
13408 }
13409 let holes = values
13410 .iter()
13411 .map(|v| {
13412 params.push(SqlValue::Text(v.clone()));
13413 "?"
13414 })
13415 .collect::<Vec<_>>()
13416 .join(", ");
13417 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
13418 }
13419 let scope = clauses.join(" AND ");
13420 let scope_params = params.len();
13421 let pk = quote_ident(&table.primary_key);
13422 if !pinned.is_empty() {
13423 let holes = pinned
13424 .iter()
13425 .map(|id| {
13426 params.push(SqlValue::Text(id.clone()));
13427 "?"
13428 })
13429 .collect::<Vec<_>>()
13430 .join(", ");
13431 clauses.push(format!("{pk} NOT IN ({holes})"));
13432 }
13433 let predicate = clauses.join(" AND ");
13434 let base = base_table(table_name);
13435 let visible = visible_table(table_name);
13436 let mut ids: Vec<SqlValue> = Vec::new();
13437 for source in [&base, &visible] {
13438 let mut stmt = self
13439 .conn
13440 .prepare_cached(&format!(
13441 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
13442 ))
13443 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13444 ids = stmt
13445 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
13446 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13447 .collect::<Result<_, _>>()
13448 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13449 if !ids.is_empty() {
13450 break;
13451 }
13452 }
13453 if !ids.is_empty() {
13454 for id in &ids {
13455 self.conn
13456 .execute(
13457 "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
13458 rusqlite::params![table_name, id],
13459 )
13460 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13461 }
13462 let holes = vec!["?"; ids.len()].join(", ");
13463 for source in [&base, &visible] {
13464 self.conn
13465 .execute(
13466 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
13467 rusqlite::params_from_iter(&ids),
13468 )
13469 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13470 }
13471 }
13472 let mut remaining = false;
13473 let mut deferred = false;
13474 for source in [&base, &visible] {
13475 remaining |= self
13476 .conn
13477 .query_row(
13478 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
13479 rusqlite::params_from_iter(¶ms),
13480 |row| row.get::<_, bool>(0),
13481 )
13482 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13483 deferred |= self
13484 .conn
13485 .query_row(
13486 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
13487 rusqlite::params_from_iter(¶ms[..scope_params]),
13488 |row| row.get::<_, bool>(0),
13489 )
13490 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13491 }
13492 Ok((remaining, deferred))
13493 }
13494
13495 fn drain_pending_evictions(&mut self) -> Result<(), String> {
13497 let pending = self.load_pending_evictions()?;
13498 if pending.is_empty() {
13499 return Ok(());
13500 }
13501 self.rebuild_overlay_if_dirty()?;
13502 for (sub_id, table_name, effective) in pending {
13503 if self.schema.table(&table_name).is_none() {
13504 self.delete_pending_evict(&sub_id)?;
13505 continue;
13506 }
13507 loop {
13508 std::thread::yield_now();
13509 self.begin_observation("syncular_evict")?;
13510 let pinned = self.pinned_row_ids(&table_name);
13511 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
13512 let (remaining, deferred) = match result {
13513 Ok(result) => result,
13514 Err(error) => {
13515 self.rollback_observation("syncular_evict");
13516 return Err(error);
13517 }
13518 };
13519 if !deferred {
13520 if let Err(error) = self.delete_pending_evict(&sub_id) {
13521 self.rollback_observation("syncular_evict");
13522 return Err(error);
13523 }
13524 }
13525 let mut batch = ChangeAccumulator::default();
13526 self.record_scope_map(&mut batch, &table_name, &effective);
13527 if let Err(error) = self.finish_observation("syncular_evict", batch) {
13528 self.rollback_observation("syncular_evict");
13529 return Err(error);
13530 }
13531 if !remaining {
13532 break;
13533 }
13534 }
13535 }
13536 Ok(())
13537 }
13538
13539 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
13542 let mut pinned = std::collections::HashSet::new();
13543 for commit in self
13544 .outbox
13545 .iter()
13546 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
13547 {
13548 for op in &commit.ops {
13549 if op.table == table {
13550 pinned.insert(op.row_id.clone());
13551 }
13552 }
13553 }
13554 pinned
13555 }
13556
13557 fn save_pending_evict(
13558 &self,
13559 sub_id: &str,
13560 table: &str,
13561 effective: &[(String, Vec<String>)],
13562 ) -> Result<(), String> {
13563 self.conn.execute(
13564 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
13565 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
13566 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13567 }
13568
13569 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
13570 self.conn
13571 .execute(
13572 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
13573 rusqlite::params![sub_id],
13574 )
13575 .map(|_| ())
13576 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13577 }
13578
13579 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
13580 let mut stmt = self
13581 .conn
13582 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
13583 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13584 let rows = stmt
13585 .query_map([], |row| {
13586 Ok((
13587 row.get::<_, String>(0)?,
13588 row.get::<_, String>(1)?,
13589 row.get::<_, String>(2)?,
13590 ))
13591 })
13592 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13593 rows.map(|row| {
13594 let (sub_id, table, json) =
13595 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13596 let value: Value = serde_json::from_str(&json)
13597 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
13598 let effective = json_to_scope_map(&value)
13599 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
13600 Ok((sub_id, table, effective))
13601 })
13602 .collect()
13603 }
13604
13605 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
13607 self.retain_failed_commits = enabled;
13608 for (commit, _) in &self.failed_commits {
13609 for operation in &commit.ops {
13610 self.overlay_dirty.table(&operation.table);
13611 }
13612 }
13613 self.failed_commits = self.load_failed_commits()?;
13614 for (commit, _) in &self.failed_commits {
13615 for operation in &commit.ops {
13616 self.overlay_dirty.table(&operation.table);
13617 }
13618 }
13619 Ok(())
13620 }
13621
13622 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
13623 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
13624 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13625 let rows = statement
13626 .query_map([], |row| {
13627 Ok((
13628 row.get::<_, String>(0)?,
13629 row.get::<_, String>(1)?,
13630 row.get::<_, String>(2)?,
13631 ))
13632 })
13633 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13634 rows.map(|row| {
13635 let (id, operations, initial) =
13636 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13637 let operations: Vec<CommitOperation> =
13638 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
13639 let initial =
13640 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
13641 Ok((
13642 OutboxCommit {
13643 client_commit_id: id,
13644 ops: operations
13645 .into_iter()
13646 .map(|op| OutboxOp {
13647 upsert: op.op == "upsert",
13648 table: op.table,
13649 row_id: op.row_id,
13650 base_version: op.base_version,
13651 values: op.values,
13652 })
13653 .collect(),
13654 },
13655 initial,
13656 ))
13657 })
13658 .collect()
13659 }
13660
13661 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
13662 let mut initial = Map::new();
13663 for operation in operations {
13664 let table = self
13665 .schema
13666 .table(&operation.table)
13667 .ok_or_else(|| "sync.unknown_table".to_owned())?;
13668 let initial_row = match self.stored_row(table, &operation.row_id, true)? {
13669 Some(row) => Some(row),
13670 None => self.visible_row(table, &operation.row_id)?,
13671 };
13672 if let Some((values, _)) = initial_row {
13673 let mut intended: Map<String, Value> = table
13674 .columns
13675 .iter()
13676 .zip(values.iter())
13677 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
13678 .collect();
13679 if let Some(patch) = &operation.values {
13680 intended.extend(patch.clone());
13681 }
13682 initial.insert(
13683 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
13684 .map_err(|_| "sync.local_corrupt".to_owned())?,
13685 Value::Object(intended),
13686 );
13687 }
13688 }
13689 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
13690 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
13691 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
13692 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13693 self.failed_commits.push((
13694 OutboxCommit {
13695 client_commit_id: id.to_owned(),
13696 ops: operations.to_vec(),
13697 },
13698 initial,
13699 ));
13700 Ok(())
13701 }
13702
13703 fn drop_failed_commits(
13704 &mut self,
13705 matches: impl Fn(&str, &Map<String, Value>) -> bool,
13706 ) -> Result<Vec<OutboxCommit>, String> {
13707 let mut doomed = self.drop_acknowledged_rows(&matches)?;
13708 for (commit, initial) in &self.failed_commits {
13709 for operation in &commit.ops {
13710 let key =
13711 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
13712 .map_err(|_| "sync.local_corrupt".to_owned())?;
13713 if initial
13714 .get(&key)
13715 .and_then(Value::as_object)
13716 .is_some_and(|values| matches(&operation.table, values))
13717 {
13718 doomed.push(commit.clone());
13719 break;
13720 }
13721 }
13722 }
13723 for commit in &doomed {
13724 self.conn
13725 .execute(
13726 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
13727 rusqlite::params![commit.client_commit_id],
13728 )
13729 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13730 }
13731 for commit in &doomed {
13732 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))?;
13733 }
13734 self.conflicts.retain(|record| {
13735 !doomed
13736 .iter()
13737 .any(|commit| commit.client_commit_id == record.client_commit_id)
13738 });
13739 self.rejections.retain(|record| {
13740 !doomed
13741 .iter()
13742 .any(|commit| commit.client_commit_id == record.client_commit_id)
13743 });
13744 self.failed_commits.retain(|(commit, _)| {
13745 !doomed
13746 .iter()
13747 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
13748 });
13749 for commit in &doomed {
13750 for operation in &commit.ops {
13751 self.overlay_dirty.table(&operation.table);
13752 }
13753 }
13754 Ok(doomed)
13755 }
13756
13757 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
13761 let prior = self.storage_failure.borrow_mut().take();
13762 let outcome = (|| -> Result<String, String> {
13763 if mutations.is_empty() {
13764 return Err(
13765 "sync.invalid_request: a commit must contain at least one operation (§6.1)"
13766 .to_owned(),
13767 );
13768 }
13769 let mut ops = Vec::with_capacity(mutations.len());
13770 for mutation in mutations {
13771 match mutation {
13772 Mutation::Patch {
13773 table,
13774 values,
13775 base_version,
13776 } => {
13777 let schema_table = self
13778 .schema
13779 .table(&table)
13780 .ok_or_else(|| "sync.unknown_table".to_owned())?;
13781 let values = normalize_values_casing(schema_table, values)?;
13782 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
13783 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
13784 }
13785 Mutation::Upsert {
13786 table,
13787 values,
13788 base_version,
13789 } => {
13790 let schema_table = self.schema.table(&table).ok_or_else(|| {
13791 format!("sync.unknown_table: unknown table {table:?}")
13792 })?;
13793 let values = normalize_values_casing(schema_table, values)?;
13797 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
13798 let values = full_row_values(schema_table, values)?;
13801 self.validate_author_encode(schema_table, &row_id, &values)?;
13807 ops.push(OutboxOp {
13808 upsert: true,
13809 table,
13810 row_id,
13811 base_version,
13812 values: Some(values),
13813 });
13814 }
13815 Mutation::Delete {
13816 table,
13817 row_id,
13818 base_version,
13819 } => {
13820 if self.schema.table(&table).is_none() {
13821 return Err(format!("sync.unknown_table: unknown table {table:?}"));
13822 }
13823 ops.push(OutboxOp {
13824 upsert: false,
13825 table,
13826 row_id,
13827 base_version,
13828 values: None,
13829 });
13830 }
13831 }
13832 }
13833 self.record_outbox_commit(ops)
13834 })();
13835 self.finish_authoring(prior, outcome)
13836 }
13837
13838 fn finish_authoring(
13844 &self,
13845 prior: Option<QueryReadFailure>,
13846 outcome: Result<String, String>,
13847 ) -> Result<String, ClientError> {
13848 let fresh = self.storage_failure.borrow_mut().take();
13849 *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
13850 match outcome {
13851 Ok(id) => Ok(id),
13852 Err(message) => Err(fresh
13853 .map(ClientError::from)
13854 .unwrap_or_else(|| ClientError::from(message))),
13855 }
13856 }
13857
13858 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
13859 let commit = OutboxCommit {
13860 client_commit_id: uuid::Uuid::new_v4().to_string(),
13861 ops,
13862 };
13863 self.begin_observation("syncular_mutation")?;
13864 let mut batch = ChangeAccumulator::default();
13865 for op in &commit.ops {
13866 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
13867 if let Some(values) = &op.values {
13868 if let Some(table) = self.schema.table(&op.table) {
13869 for scope in &table.scope_variables {
13870 if let Some(Value::String(value)) = values.get(&scope.column) {
13871 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
13872 precise = true;
13873 }
13874 }
13875 }
13876 }
13877 if !precise {
13878 batch.table(&op.table);
13879 }
13880 }
13881 if let Err(error) = self.persist_outbox_insert(&commit) {
13882 self.rollback_observation("syncular_mutation");
13883 return Err(error);
13884 }
13885 let was_dirty = self.overlay_dirty.snapshot();
13886 if let Err(error) = self.rebuild_overlay_if_dirty() {
13891 self.rollback_observation("syncular_mutation");
13892 self.overlay_dirty.restore(was_dirty.clone());
13893 return Err(error);
13894 }
13895 if let Some(error) = commit
13896 .ops
13897 .iter()
13898 .find_map(|op| self.apply_outbox_op(op).err())
13899 {
13900 self.rollback_observation("syncular_mutation");
13901 self.overlay_dirty.restore(was_dirty.clone());
13902 return Err(error);
13903 }
13904 let id = commit.client_commit_id.clone();
13905 self.outbox.push(commit);
13906 batch.status = true;
13907 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
13908 self.rollback_observation("syncular_mutation");
13909 self.outbox.pop();
13910 self.overlay_dirty.restore(was_dirty.clone());
13911 return Err(error);
13912 }
13913 Ok(id)
13914 }
13915
13916 pub fn patch(
13922 &mut self,
13923 table: &str,
13924 row_id: &str,
13925 partial: Map<String, Value>,
13926 base_version: Option<i64>,
13927 ) -> Result<String, ClientError> {
13928 let prior = self.storage_failure.borrow_mut().take();
13929 let outcome = self.perform_patch(table, row_id, partial, base_version);
13930 self.finish_authoring(prior, outcome)
13931 }
13932
13933 fn perform_patch(
13936 &mut self,
13937 table: &str,
13938 row_id: &str,
13939 partial: Map<String, Value>,
13940 base_version: Option<i64>,
13941 ) -> Result<String, String> {
13942 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
13943 self.record_outbox_commit(vec![operation])
13944 }
13945
13946 fn prepare_patch(
13947 &self,
13948 table: &str,
13949 row_id: &str,
13950 partial: Map<String, Value>,
13951 base_version: Option<i64>,
13952 ) -> Result<OutboxOp, String> {
13953 let schema_table = self
13954 .schema
13955 .table(table)
13956 .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
13957 let partial = normalize_values_casing(schema_table, partial)?;
13958 if let Some(pk) = partial.get(&schema_table.primary_key) {
13959 if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
13960 return Err(format!(
13961 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
13962 ));
13963 }
13964 }
13965 let exists: bool = self
13968 .conn
13969 .query_row(
13970 &format!(
13971 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
13972 visible_table(table),
13973 row_id_predicate(schema_table)
13974 ),
13975 rusqlite::params![row_id],
13976 |row| row.get(0),
13977 )
13978 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13979 if !exists {
13980 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
13981 }
13982 for scope in &schema_table.scope_variables {
13992 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
13993 continue;
13994 }
13995 let Some(column) = schema_table
13996 .columns
13997 .iter()
13998 .find(|column| column.name == scope.column)
13999 else {
14000 continue;
14001 };
14002 let stored = self
14003 .conn
14004 .query_row(
14005 &format!(
14006 "SELECT {} FROM {} WHERE {}",
14007 quote_ident(&scope.column),
14008 visible_table(&schema_table.name),
14009 row_id_predicate(schema_table)
14010 ),
14011 rusqlite::params![row_id],
14012 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
14013 )
14014 .optional()
14015 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14016 let incoming = json_to_column_value(column, partial.get(&scope.column))
14021 .ok()
14022 .map(|value| column_value_to_json(&value));
14023 let equal = stored.as_ref().is_some_and(|stored| {
14027 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
14028 });
14029 if !equal {
14030 return Err(format!(
14031 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
14032 scope.column
14033 ));
14034 }
14035 }
14036 let mut values = partial;
14037 for scope in &schema_table.scope_variables {
14040 values.remove(&scope.column);
14041 }
14042 let pk_value = match schema_table.columns[schema_table.pk_index].ty {
14043 ColumnType::Integer => match row_id.parse::<i64>() {
14045 Ok(v)
14046 if v.to_string() == row_id
14047 && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
14048 .contains(&v) =>
14049 {
14050 Value::from(v)
14051 }
14052 _ => {
14053 return Err(format!(
14054 "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
14055 ))
14056 }
14057 },
14058 ColumnType::Boolean => match row_id {
14059 "true" => Value::Bool(true),
14060 "false" => Value::Bool(false),
14061 _ => {
14062 return Err(format!(
14063 "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
14064 ))
14065 }
14066 },
14067 _ => Value::from(row_id.to_owned()),
14068 };
14069 values.insert(schema_table.primary_key.clone(), pk_value);
14070 self.validate_author_encode(schema_table, row_id, &values)?;
14076 Ok(OutboxOp {
14077 upsert: true,
14078 table: table.to_owned(),
14079 row_id: row_id.to_owned(),
14080 base_version,
14081 values: Some(values),
14082 })
14083 }
14084
14085 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
14090 let selector = self.encryption.key_id_columns.get(&table.name)?;
14091 let index = table
14092 .columns
14093 .iter()
14094 .position(|column| &column.name == selector)?;
14095 let sql = format!(
14096 "SELECT {} FROM {} WHERE {}",
14097 quote_ident(selector),
14098 visible_table(&table.name),
14099 row_id_predicate(table)
14100 );
14101 let key: Option<String> = self
14102 .conn
14103 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
14104 .ok()?;
14105 let key = key.filter(|key| !key.is_empty())?;
14106 let mut fallback: Row = vec![None; table.columns.len()];
14107 fallback[index] = Some(ColumnValue::String(key));
14108 Some(fallback)
14109 }
14110
14111 fn validate_author_encode(
14116 &self,
14117 table: &TableSchema,
14118 row_id: &str,
14119 values: &Map<String, Value>,
14120 ) -> Result<(), String> {
14121 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
14122 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
14123 result => result.map(|_| ()),
14124 }
14125 }
14126
14127 fn drop_unencodable_outbox(
14141 &mut self,
14142 with_encryption: bool,
14143 batch: Option<&mut ChangeAccumulator>,
14144 ) -> Result<bool, String> {
14145 let failures = self
14146 .outbox
14147 .iter()
14148 .filter_map(|commit| {
14149 commit
14150 .ops
14151 .iter()
14152 .find_map(|op| {
14153 let values = op.values.as_ref().filter(|_| op.upsert)?;
14154 let table = self.schema.table(&op.table)?;
14155 let fallback = self.stored_key_fallback(table, &op.row_id);
14156 match encode_sparse_row_json(
14157 table,
14158 &op.row_id,
14159 values,
14160 &self.encryption,
14161 fallback.as_ref(),
14162 ) {
14163 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
14164 with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
14165 }
14166 Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
14171 LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
14172 OUTBOX_INCOMPATIBLE_CODE,
14173 )),
14174 _ => None,
14175 }
14176 })
14177 .map(|(error, code)| (commit.clone(), error, code))
14178 })
14179 .collect::<Vec<_>>();
14180 if failures.is_empty() {
14181 return Ok(false);
14182 }
14183 let prior_outbox = self.outbox.clone();
14189 let prior_rejections = self.rejections.clone();
14190 let prior_failed_commits = self.failed_commits.clone();
14194 let prior_changes = self.change_queue.len();
14195 let prior_intents = self.sync_intent_queue.len();
14196 let prior_last_change = self.last_change.clone();
14197 let prior_overlay_dirty = self.overlay_dirty.snapshot();
14198 let owns_observation = batch.is_none();
14199 let owns_transaction = self.conn.is_autocommit();
14200 let mut domains = ChangeAccumulator::default();
14201 if owns_observation {
14202 self.begin_observation("syncular_value_recovery")?;
14203 }
14204 let dropped = (|| -> Result<(), String> {
14205 for (commit, message, code) in failures {
14206 if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
14207 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
14208 }
14209 let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
14210 let rejection = RejectionRecord {
14211 client_commit_id: commit.client_commit_id.clone(),
14212 op_index: 0,
14213 code: code.to_owned(),
14214 message,
14215 retryable: false,
14216 details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
14217 field_paths: None,
14218 reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
14219 required_action: None,
14220 references: None,
14221 }),
14222 operation: representable
14223 .then(|| commit.ops.first())
14224 .flatten()
14225 .map(CommitOperation::from),
14226 };
14227 self.persist_commit_outcome(
14228 &commit.client_commit_id,
14229 CommitOutcomeStatus::Rejected,
14230 &[CommitOperationOutcome::Error {
14231 rejection: rejection.clone(),
14232 }],
14233 representable.then_some(commit.ops.as_slice()),
14234 )?;
14235 self.delete_outbox_persisted(&commit.client_commit_id)?;
14236 self.outbox
14237 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
14238 self.rejections.push(rejection);
14239 for operation in &commit.ops {
14240 domains.table(operation.table.as_str());
14241 }
14242 }
14243 domains.status = true;
14244 domains.rejections = true;
14245 domains.outcomes = true;
14246 self.prune_commit_outcomes()?;
14247 self.overlay_dirty.set(true);
14249 self.rebuild_overlay()?;
14250 Ok(())
14251 })();
14252 let result = match batch {
14253 Some(batch) => {
14254 if dropped.is_ok() {
14255 for table in domains.tables.keys() {
14256 batch.table(table.as_str());
14257 }
14258 batch.status |= domains.status;
14259 batch.rejections |= domains.rejections;
14260 batch.outcomes |= domains.outcomes;
14261 }
14262 dropped
14263 }
14264 None => {
14265 dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
14266 }
14267 };
14268 if let Err(error) = result {
14269 if owns_observation {
14270 if owns_transaction {
14271 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
14272 let mut failure = self.storage_failure.borrow_mut();
14273 let failure =
14274 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
14275 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
14276 }
14277 } else {
14278 self.rollback_observation("syncular_value_recovery");
14279 }
14280 }
14281 self.outbox = prior_outbox;
14282 self.rejections = prior_rejections;
14283 self.failed_commits = prior_failed_commits;
14284 self.change_queue.truncate(prior_changes);
14285 self.sync_intent_queue.truncate(prior_intents);
14286 self.last_change = prior_last_change;
14287 self.overlay_dirty.restore(prior_overlay_dirty);
14288 return Err(error);
14289 }
14290 Ok(true)
14291 }
14292
14293 pub fn pending_commit_ids(&self) -> Vec<String> {
14294 self.outbox
14295 .iter()
14296 .map(|c| c.client_commit_id.clone())
14297 .collect()
14298 }
14299
14300 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
14304 let mut payloads = Vec::new();
14305 for commit in &self.outbox {
14306 for op in &commit.ops {
14307 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
14308 continue;
14309 };
14310 let Some(table) = self.schema.table(&op.table) else {
14311 continue;
14312 };
14313 let fallback = self.stored_key_fallback(table, &op.row_id);
14314 if let Ok(payload) = encode_sparse_row_json(
14315 table,
14316 &op.row_id,
14317 values,
14318 &self.encryption,
14319 fallback.as_ref(),
14320 ) {
14321 payloads.push(payload);
14322 }
14323 }
14324 }
14325 payloads
14326 }
14327
14328 pub fn conflicts(&self) -> &[ConflictRecord] {
14329 &self.conflicts
14330 }
14331
14332 pub fn rejections(&self) -> &[RejectionRecord] {
14333 &self.rejections
14334 }
14335
14336 pub fn resolve_commit_outcome(
14337 &mut self,
14338 input: ResolveCommitOutcomeInput,
14339 ) -> Result<CommitOutcome, String> {
14340 let current = self
14341 .commit_outcome(&input.client_commit_id)?
14342 .ok_or_else(|| {
14343 format!(
14344 "sync.outcome_not_found: no durable outcome exists for {:?}",
14345 input.client_commit_id
14346 )
14347 })?;
14348 if current.resolution != CommitOutcomeResolution::Active {
14349 return Ok(current);
14350 }
14351 if input.resolution == CommitOutcomeResolution::Active {
14352 return Err("sync.invalid_request: resolution must leave active state".to_owned());
14353 }
14354 match input.resolution {
14355 CommitOutcomeResolution::Superseded => {
14356 let replacement = input
14357 .replacement_client_commit_id
14358 .as_deref()
14359 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
14360 .ok_or_else(|| {
14361 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
14362 .to_owned()
14363 })?;
14364 let _ = replacement;
14365 }
14366 _ if input.replacement_client_commit_id.is_some() => {
14367 return Err(
14368 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
14369 .to_owned(),
14370 );
14371 }
14372 _ => {}
14373 }
14374 let allowed = match current.status {
14375 CommitOutcomeStatus::Conflict => matches!(
14376 input.resolution,
14377 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
14378 ),
14379 CommitOutcomeStatus::Rejected => {
14380 matches!(
14381 input.resolution,
14382 CommitOutcomeResolution::Superseded
14383 | CommitOutcomeResolution::ResolvedKeepServer
14384 )
14385 }
14386 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
14387 input.resolution == CommitOutcomeResolution::Dismissed
14388 }
14389 };
14390 if !allowed {
14391 return Err(format!(
14392 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
14393 input.resolution, current.status
14394 ));
14395 }
14396
14397 self.begin_observation("syncular_outcome_resolution")?;
14398 let previous_failed = self.failed_commits.clone();
14399 let previous_conflicts = self.conflicts.clone();
14400 let previous_rejections = self.rejections.clone();
14401 let result = (|| {
14402 self.conn
14403 .execute(
14404 "UPDATE _syncular_commit_outcomes
14405 SET resolution = ?1, resolved_at_ms = ?2,
14406 replacement_client_commit_id = ?3
14407 WHERE client_commit_id = ?4 AND resolution = 'active'",
14408 rusqlite::params![
14409 Self::outcome_resolution_name(input.resolution),
14410 self.clock_now_ms(),
14411 input.replacement_client_commit_id,
14412 input.client_commit_id
14413 ],
14414 )
14415 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14416 let resolved = self
14417 .commit_outcome(¤t.client_commit_id)?
14418 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
14419 self.conn
14420 .execute(
14421 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
14422 rusqlite::params![input.client_commit_id],
14423 )
14424 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14425 for (commit, _) in &self.failed_commits {
14426 if commit.client_commit_id == input.client_commit_id {
14427 for operation in &commit.ops {
14428 self.overlay_dirty.table(&operation.table);
14429 }
14430 }
14431 }
14432 self.failed_commits
14433 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
14434 self.prune_commit_outcomes()?;
14435 Ok(resolved)
14436 })();
14437 let resolved = match result {
14438 Ok(outcome) => outcome,
14439 Err(error) => {
14440 self.rollback_observation("syncular_outcome_resolution");
14441 self.failed_commits = previous_failed;
14442 self.conflicts = previous_conflicts;
14443 self.rejections = previous_rejections;
14444 return Err(error);
14445 }
14446 };
14447 self.conflicts
14448 .retain(|record| record.client_commit_id != current.client_commit_id);
14449 self.rejections
14450 .retain(|record| record.client_commit_id != current.client_commit_id);
14451 let mut batch = ChangeAccumulator {
14452 conflicts: current.status == CommitOutcomeStatus::Conflict,
14453 rejections: current.status == CommitOutcomeStatus::Rejected,
14454 outcomes: true,
14455 ..ChangeAccumulator::default()
14456 };
14457 if let Some(operations) = ¤t.operations {
14458 for operation in operations {
14459 batch.table(&operation.table);
14460 }
14461 }
14462 if let Err(error) = self
14463 .rebuild_overlay_if_dirty()
14464 .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
14465 {
14466 self.rollback_observation("syncular_outcome_resolution");
14467 self.failed_commits = previous_failed;
14468 self.conflicts = previous_conflicts;
14469 self.rejections = previous_rejections;
14470 return Err(error);
14471 }
14472 Ok(resolved)
14473 }
14474
14475 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
14476 self.schema_floor.as_ref()
14477 }
14478
14479 pub fn lease_state(&self) -> Option<&LeaseState> {
14481 self.lease_state.as_ref()
14482 }
14483
14484 fn record_lease_error(&mut self, code: &str) {
14487 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
14488 return;
14489 }
14490 let mut next = self.lease_state.clone().unwrap_or_default();
14491 next.error_code = Some(code.to_owned());
14492 self.set_lease_state(Some(next));
14493 }
14494
14495 fn set_lease_state(&mut self, next: Option<LeaseState>) {
14496 if self.lease_state == next {
14497 return;
14498 }
14499 if self.begin_observation("syncular_lease").is_err() {
14500 return;
14501 }
14502 self.lease_state = next;
14503 if let Some(lease) = &self.lease_state {
14504 if let Ok(json) = serde_json::to_string(lease) {
14505 self.set_meta(LEASE_STATE_KEY, &json);
14506 }
14507 } else {
14508 self.delete_meta(LEASE_STATE_KEY);
14509 }
14510 let batch = ChangeAccumulator {
14511 status: true,
14512 ..ChangeAccumulator::default()
14513 };
14514 if self.finish_observation("syncular_lease", batch).is_err() {
14515 self.rollback_observation("syncular_lease");
14516 }
14517 }
14518
14519 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
14520 if self.schema_floor == next {
14521 return;
14522 }
14523 if self.begin_observation("syncular_schema_floor").is_err() {
14524 return;
14525 }
14526 self.schema_floor = next;
14527 self.stopped = self.schema_floor.is_some();
14528 if let Some(floor) = &self.schema_floor {
14529 if let Ok(json) = serde_json::to_string(floor) {
14530 self.set_meta(SCHEMA_FLOOR_KEY, &json);
14531 }
14532 } else {
14533 self.delete_meta(SCHEMA_FLOOR_KEY);
14534 }
14535 let batch = ChangeAccumulator {
14536 status: true,
14537 ..ChangeAccumulator::default()
14538 };
14539 if self
14540 .finish_observation("syncular_schema_floor", batch)
14541 .is_err()
14542 {
14543 self.rollback_observation("syncular_schema_floor");
14544 }
14545 }
14546
14547 fn set_upgrading(&mut self, value: bool) {
14548 if self.upgrading == value {
14549 return;
14550 }
14551 if self.begin_observation("syncular_upgrading").is_err() {
14552 return;
14553 }
14554 self.upgrading = value;
14555 let batch = ChangeAccumulator {
14556 status: true,
14557 ..ChangeAccumulator::default()
14558 };
14559 if self
14560 .finish_observation("syncular_upgrading", batch)
14561 .is_err()
14562 {
14563 self.rollback_observation("syncular_upgrading");
14564 }
14565 }
14566
14567 pub fn sync_needed(&self) -> bool {
14568 self.sync_needed
14569 }
14570
14571 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
14572 let sub = self.subs.iter().find(|s| s.id == id)?;
14573 Some(SubscriptionStateView {
14574 id: sub.id.clone(),
14575 table: sub.table.clone(),
14576 status: sub.state.name().to_owned(),
14577 cursor: sub.cursor,
14578 has_resume_token: sub.bootstrap_state.is_some(),
14579 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
14580 reason_code: sub.reason_code.clone(),
14581 })
14582 }
14583
14584 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
14585 let schema_table = self
14586 .schema
14587 .table(table)
14588 .ok_or_else(|| format!("unknown table {table:?}"))?;
14589 let sql = format!(
14590 "SELECT * FROM {} ORDER BY {} ASC",
14591 visible_table(table),
14592 quote_ident(&schema_table.primary_key)
14593 );
14594 let mut stmt = self
14595 .conn
14596 .prepare(&sql)
14597 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14598 let mut rows = stmt
14599 .query([])
14600 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14601 let mut out = Vec::new();
14602 while let Some(row) = rows
14603 .next()
14604 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14605 {
14606 let mut values = Map::new();
14607 for (i, column) in schema_table.columns.iter().enumerate() {
14608 let value = row
14609 .get_ref(i)
14610 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14611 values.insert(column.name.clone(), sql_ref_to_json(column, value));
14612 }
14613 let version: i64 = row
14614 .get(schema_table.columns.len())
14615 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14616 let row_id = match values.get(&schema_table.primary_key) {
14617 Some(Value::String(s)) => s.clone(),
14618 Some(Value::Number(n)) => n.to_string(),
14619 Some(Value::Bool(b)) => b.to_string(),
14620 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
14621 };
14622 out.push(RowState {
14623 row_id,
14624 version,
14625 values,
14626 });
14627 }
14628 Ok(out)
14629 }
14630
14631 #[cfg(feature = "crdt-yjs")]
14647 fn crdt_column_bytes(
14648 &self,
14649 table: &str,
14650 row_id: &str,
14651 column: &str,
14652 ) -> Result<Option<Vec<u8>>, String> {
14653 let schema_table = self
14654 .schema
14655 .table(table)
14656 .ok_or_else(|| format!("unknown table {table:?}"))?;
14657 let col = schema_table
14658 .columns
14659 .iter()
14660 .find(|c| c.name == column)
14661 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
14662 if col.ty != ColumnType::Crdt {
14663 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
14664 }
14665 let sql = format!(
14666 "SELECT {} FROM {} WHERE {}",
14667 quote_ident(column),
14668 visible_table(table),
14669 row_id_predicate(schema_table)
14670 );
14671 let bytes: Option<Vec<u8>> = self
14672 .conn
14673 .prepare_cached(&sql)
14674 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14675 .query_row(rusqlite::params![row_id], |row| {
14676 row.get::<_, Option<Vec<u8>>>(0)
14677 })
14678 .map_err(|e| match e {
14679 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
14680 other => Self::sqlite_failure(&self.storage_failure, other),
14681 })?;
14682 Ok(bytes)
14683 }
14684
14685 #[cfg(feature = "crdt-yjs")]
14689 pub fn crdt_text(
14690 &self,
14691 table: &str,
14692 row_id: &str,
14693 column: &str,
14694 name: &str,
14695 ) -> Result<String, String> {
14696 let bytes = self
14697 .crdt_column_bytes(table, row_id, column)?
14698 .unwrap_or_default();
14699 crate::crdt::text(&bytes, name)
14700 }
14701
14702 #[cfg(feature = "crdt-yjs")]
14706 pub fn crdt_insert_text(
14707 &mut self,
14708 table: &str,
14709 row_id: &str,
14710 column: &str,
14711 name: &str,
14712 index: u32,
14713 value: &str,
14714 ) -> Result<String, ClientError> {
14715 let prior = self.storage_failure.borrow_mut().take();
14716 let outcome = (|| -> Result<String, String> {
14717 let current = self
14718 .crdt_column_bytes(table, row_id, column)?
14719 .unwrap_or_default();
14720 let update = crate::crdt::insert_text(¤t, name, index, value)?;
14721 self.perform_crdt_push_update(table, row_id, column, &update)
14722 })();
14723 self.finish_authoring(prior, outcome)
14724 }
14725
14726 #[cfg(feature = "crdt-yjs")]
14730 pub fn crdt_delete_text(
14731 &mut self,
14732 table: &str,
14733 row_id: &str,
14734 column: &str,
14735 name: &str,
14736 index: u32,
14737 len: u32,
14738 ) -> Result<String, ClientError> {
14739 let prior = self.storage_failure.borrow_mut().take();
14740 let outcome = (|| -> Result<String, String> {
14741 let current = self
14742 .crdt_column_bytes(table, row_id, column)?
14743 .unwrap_or_default();
14744 let update = crate::crdt::delete_text(¤t, name, index, len)?;
14745 self.perform_crdt_push_update(table, row_id, column, &update)
14746 })();
14747 self.finish_authoring(prior, outcome)
14748 }
14749
14750 #[cfg(feature = "crdt-yjs")]
14755 pub fn crdt_apply_update(
14756 &mut self,
14757 table: &str,
14758 row_id: &str,
14759 column: &str,
14760 update: &[u8],
14761 ) -> Result<String, ClientError> {
14762 let prior = self.storage_failure.borrow_mut().take();
14763 let outcome = (|| -> Result<String, String> {
14764 let current = self
14765 .crdt_column_bytes(table, row_id, column)?
14766 .unwrap_or_default();
14767 let next = crate::crdt::apply_update(¤t, update)?;
14768 self.perform_crdt_push_update(table, row_id, column, &next)
14769 })();
14770 self.finish_authoring(prior, outcome)
14771 }
14772
14773 #[cfg(feature = "crdt-yjs")]
14780 fn perform_crdt_push_update(
14781 &mut self,
14782 table: &str,
14783 row_id: &str,
14784 column: &str,
14785 crdt_bytes: &[u8],
14786 ) -> Result<String, String> {
14787 let mut bytes_obj = Map::new();
14788 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
14789 let mut partial = Map::new();
14790 partial.insert(column.to_owned(), Value::Object(bytes_obj));
14791 self.perform_patch(table, row_id, partial, None)
14792 }
14793
14794 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
14809 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
14810 }
14811
14812 #[cfg(feature = "bench-internals")]
14814 #[doc(hidden)]
14815 pub fn benchmark_connection(&mut self) -> &mut Connection {
14816 &mut self.conn
14817 }
14818
14819 #[cfg(feature = "bench-internals")]
14821 #[doc(hidden)]
14822 pub fn benchmark_phases(
14823 &self,
14824 enabled: Option<bool>,
14825 reset: bool,
14826 ) -> Result<Option<Value>, String> {
14827 self.benchmark_phases.configure(enabled, reset)?;
14828 Ok(self.benchmark_phases.snapshot())
14829 }
14830
14831 pub fn authority_snapshot(&self) -> Result<Value, String> {
14833 validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
14834 if self.limits.authority_reads.is_empty() {
14835 return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
14836 }
14837 self.conn
14838 .execute_batch("SAVEPOINT authority_snapshot")
14839 .map_err(|e| e.to_string())?;
14840 let result = (|| {
14841 let revision = self
14842 .get_meta(LOCAL_REVISION_KEY)
14843 .ok_or("sync.local_corrupt: authority revision is missing")?;
14844 let parsed_revision = revision
14845 .parse::<u64>()
14846 .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
14847 if parsed_revision.to_string() != revision {
14848 return Err("sync.local_corrupt: authority revision is invalid".into());
14849 }
14850 let mut tables = Vec::new();
14851 for read in &self.limits.authority_reads {
14852 let table = self
14853 .schema
14854 .table(&read.table)
14855 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
14856 let scalar = |column: &str, value: &Value| -> Result<String, String> {
14857 let ty = table
14858 .columns
14859 .iter()
14860 .find(|c| c.name == column)
14861 .expect("validated authority column")
14862 .ty;
14863 if ty == ColumnType::Boolean {
14864 if let Some(value) = value.as_bool() {
14865 return Ok(value.to_string());
14866 }
14867 return match value.as_i64() {
14868 Some(0) => Ok("false".into()),
14869 Some(1) => Ok("true".into()),
14870 _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
14871 };
14872 }
14873 match value {
14874 Value::String(s) => Ok(s.clone()),
14875 Value::Number(n) => Ok(n.to_string()),
14876 Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
14877 .get("$bigint")
14878 .and_then(Value::as_str)
14879 .map(str::to_owned)
14880 .ok_or("sync.local_corrupt: authority integer is invalid".into()),
14881 _ => {
14882 Err("sync.local_corrupt: authority scalar is missing or invalid".into())
14883 }
14884 }
14885 };
14886 let matches = |row: &QueryRow| -> Result<bool, String> {
14887 for (variable, values) in &read.scopes {
14888 let column = table
14889 .scope_column(variable)
14890 .expect("validated authority scope");
14891 let value = row
14892 .get(column)
14893 .ok_or("sync.local_corrupt: authority scope column is missing")?;
14894 if value.is_null() || !values.contains(&scalar(column, value)?) {
14895 return Ok(false);
14896 }
14897 }
14898 Ok(true)
14899 };
14900 let selected = read
14901 .columns
14902 .iter()
14903 .map(|c| quote_ident(c))
14904 .collect::<Vec<_>>()
14905 .join(",");
14906 let mut intent = BTreeSet::new();
14907 let mut relevant = BTreeSet::new();
14908 for row in query_connection(&self.conn,
14909 "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=?",
14910 &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
14911 let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
14912 intent.insert(id.clone());
14913 if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
14914 let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
14915 if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
14916 }
14917 }
14918 let mut rows = BTreeMap::new();
14919 for full in [base_table(&read.table), visible_table(&read.table)] {
14920 for mut values in query_connection(
14921 &self.conn,
14922 &format!(
14923 "SELECT {selected},_syncular_version AS authority_version FROM {full}"
14924 ),
14925 &[],
14926 )
14927 .map_err(|e| e.to_string())?
14928 {
14929 if !matches(&values)? {
14930 continue;
14931 }
14932 let id = scalar(
14933 &table.primary_key,
14934 values
14935 .get(&table.primary_key)
14936 .expect("selected primary key"),
14937 )?;
14938 relevant.insert(id.clone());
14939 if full == base_table(&read.table) {
14940 let version = values
14941 .remove("authority_version")
14942 .expect("selected version");
14943 let version = version
14944 .as_i64()
14945 .ok_or("sync.local_corrupt: authority server version is invalid")?;
14946 if version >= 0 {
14947 rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
14948 }
14949 }
14950 }
14951 }
14952 intent.retain(|id| relevant.contains(id));
14953 let rows = rows.into_values().collect::<Vec<_>>();
14954 let mut persisted = Vec::new();
14955 for row in query_connection(
14956 &self.conn,
14957 "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
14958 &[Value::String(read.table.clone())],
14959 )
14960 .map_err(|e| e.message)?
14961 {
14962 let state: Value = serde_json::from_str(
14963 row.get("state_json")
14964 .and_then(Value::as_str)
14965 .ok_or("sync.local_corrupt: authority subscription state is missing")?,
14966 )
14967 .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
14968 let effective = state.get("effectiveScopes").and_then(Value::as_object);
14969 let status = state
14970 .get("status")
14971 .and_then(Value::as_str)
14972 .ok_or("sync.local_corrupt: authority subscription status is missing")?;
14973 let cursor = state
14974 .get("cursor")
14975 .and_then(Value::as_i64)
14976 .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
14977 let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
14978 && status == "active"
14979 && cursor >= 0
14980 && state.get("bootstrapState").is_none_or(Value::is_null)
14981 && state.get("params").is_none_or(Value::is_null);
14982 let exposed = effective.map(|s| {
14983 read.scopes
14984 .keys()
14985 .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
14986 .collect::<Map<_, _>>()
14987 });
14988 let requested = read
14989 .scopes
14990 .keys()
14991 .map(|v| {
14992 (
14993 v.clone(),
14994 state
14995 .get("requested")
14996 .and_then(|s| s.get(v))
14997 .cloned()
14998 .unwrap_or(json!([])),
14999 )
15000 })
15001 .collect::<Map<_, _>>();
15002 persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
15003 }
15004 fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
15005 match selectors.split_first() {
15006 None => !candidates.is_empty(),
15007 Some(((variable, values), rest)) => values.iter().all(|value| {
15008 covered(
15009 rest,
15010 &candidates
15011 .iter()
15012 .copied()
15013 .filter(|p| {
15014 p["effectiveScopes"][variable.as_str()]
15015 .as_array()
15016 .is_some_and(|held| {
15017 held.contains(&Value::String(value.clone()))
15018 || held.contains(&Value::String("*".into()))
15019 })
15020 })
15021 .collect::<Vec<_>>(),
15022 )
15023 }),
15024 }
15025 }
15026 let coverage = if covered(
15027 &read.scopes.iter().collect::<Vec<_>>(),
15028 &persisted
15029 .iter()
15030 .filter(|p| p["complete"] == true)
15031 .collect::<Vec<_>>(),
15032 ) {
15033 "complete"
15034 } else if persisted.iter().any(|p| {
15035 p["status"] == "active"
15036 && read.scopes.iter().all(|(v, values)| {
15037 values.iter().any(|value| {
15038 p["requestedScopes"][v].as_array().is_some_and(|held| {
15039 held.contains(&Value::String(value.clone()))
15040 || held.contains(&Value::String("*".into()))
15041 })
15042 })
15043 })
15044 }) {
15045 "pending"
15046 } else {
15047 "missing"
15048 };
15049 tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
15050 }
15051 Ok(
15052 json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
15053 )
15054 })();
15055 match result {
15056 Ok(snapshot) => {
15057 self.conn
15058 .execute_batch("RELEASE authority_snapshot")
15059 .map_err(|e| e.to_string())?;
15060 Ok(snapshot)
15061 }
15062 Err(error) => {
15063 self.conn
15064 .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
15065 .map_err(|e| format!("{error}; rollback failed: {e}"))?;
15066 Err(error)
15067 }
15068 }
15069 }
15070
15071 pub fn query_snapshot(
15075 &mut self,
15076 sql: &str,
15077 params: &[QueryValue],
15078 coverage: &[WindowCoverage],
15079 owner: Option<QueryOwner<'_>>,
15080 ) -> Result<QuerySnapshot, String> {
15081 let result = snapshot_connection(&self.conn, sql, params, coverage);
15082 if let Some(owner) = owner {
15083 self.record_query_read(owner, result.as_ref().err());
15084 }
15085 result.map_err(|failure| failure.to_string())
15086 }
15087
15088 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
15092 let position = self
15093 .query_failures
15094 .iter()
15095 .position(|entry| entry.id == owner.id);
15096 let Some(failure) = failure else {
15097 if let Some(index) = position {
15098 self.query_failures.remove(index);
15099 }
15100 return;
15101 };
15102 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
15103 .into_iter()
15104 .map(str::to_owned)
15105 .collect();
15106 let code = failure.code.unwrap_or("client.query_failed").to_owned();
15107 if position.is_some_and(|index| {
15108 let entry = &self.query_failures[index];
15109 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
15110 }) {
15111 return;
15112 }
15113 if let Some(index) = position {
15114 self.query_failures.remove(index);
15115 }
15116 self.query_failures.push(DiagnosticQueryFailure {
15117 id: owner.id.to_owned(),
15118 tables,
15119 code,
15120 sqlite_code: failure.sqlite_code,
15121 at_ms: self.clock_now_ms(),
15122 });
15123 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
15124 self.query_failures.remove(0);
15125 }
15126 }
15127
15128 fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
15131 #[cfg(feature = "bench-internals")]
15132 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
15133 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
15134 let max_commits = self.limits.max_push_commits_per_request;
15135 let max_ops = self
15136 .limits
15137 .max_push_operations_per_request
15138 .unwrap_or(PUSH_OPS_PER_REQUEST);
15139 let byte_cap = self.limits.max_push_request_bytes;
15140 let accept = self.limits.accept.unwrap_or(if url_capable {
15143 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
15144 } else {
15145 DEFAULT_ACCEPT
15146 });
15147 let header = Frame::ReqHeader {
15148 client_id: self.client_id.clone(),
15149 schema_version: self.schema.version,
15150 log_epoch: log_epoch.clone(),
15151 };
15152 let pull = Frame::PullHeader {
15153 limit_commits: self.limits.limit_commits.unwrap_or(0),
15154 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
15155 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
15156 accept,
15157 };
15158 let mut fresh = Vec::new();
15159 let mut sub_frames = Vec::new();
15160 for sub in &self.subs {
15161 if sub.state != SubState::Active {
15162 continue;
15163 }
15164 let mut scopes = sub.requested.clone();
15165 sort_scope_map(&mut scopes);
15166 sub_frames.push(Frame::Subscription {
15167 id: sub.id.clone(),
15168 table: sub.table.clone(),
15169 scopes,
15170 params: sub.params.clone().map(RawJson),
15171 cursor: sub.cursor,
15172 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
15173 });
15174 fresh.push((
15175 sub.id.clone(),
15176 sub.cursor < 0 && sub.bootstrap_state.is_none(),
15177 ));
15178 }
15179 let fixed_bytes = match byte_cap {
15182 Some(_) => {
15183 let mut frames = vec![header.clone(), pull.clone()];
15184 frames.extend(sub_frames.iter().cloned());
15185 encode_message(&Message {
15186 wire_version: WIRE_VERSION,
15187 msg_kind: MsgKind::Request,
15188 frames,
15189 })
15190 .len()
15191 }
15192 None => 0,
15193 };
15194 if let Some(cap) = byte_cap {
15195 if fixed_bytes > cap {
15196 return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
15197 }
15198 }
15199 let base_bytes = match byte_cap {
15200 Some(_) => encode_message(&Message {
15201 wire_version: WIRE_VERSION,
15202 msg_kind: MsgKind::Request,
15203 frames: vec![header.clone(), pull.clone()],
15204 })
15205 .len(),
15206 None => 0,
15207 };
15208 let total_outbox = if log_epoch.is_some() {
15209 self.outbox.len()
15210 } else {
15211 0
15212 };
15213 let mut push_frames: Vec<Frame> = Vec::new();
15214 let mut pushed_ids = Vec::new();
15215 let mut ops_in_request = 0usize;
15216 let mut bytes_in_request = 0usize;
15217 let mut deferred_commits = 0usize;
15218 #[cfg(feature = "bench-internals")]
15219 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
15220 for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
15221 if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
15225 deferred_commits = total_outbox - index;
15226 break;
15227 }
15228 if ops_in_request + commit.ops.len() > max_ops {
15229 if push_frames.is_empty() {
15230 return Err(Self::push_capacity_outcome(
15231 "operations",
15232 max_ops,
15233 commit.ops.len(),
15234 Some(commit.client_commit_id.as_str()),
15235 ));
15236 }
15237 deferred_commits = total_outbox - index;
15238 break;
15239 }
15240 let operations = commit
15241 .ops
15242 .iter()
15243 .map(|op| {
15244 let payload = op.values.as_ref().and_then(|values| {
15245 let table = self.schema.table(&op.table)?;
15246 let fallback = self.stored_key_fallback(table, &op.row_id);
15255 encode_sparse_row_json(
15256 table,
15257 &op.row_id,
15258 values,
15259 &self.encryption,
15260 fallback.as_ref(),
15261 )
15262 .ok()
15263 });
15264 ssp2::model::Operation {
15265 table: op.table.clone(),
15266 row_id: op.row_id.clone(),
15267 op: if op.upsert { Op::Upsert } else { Op::Delete },
15268 base_version: op.base_version,
15269 payload,
15270 }
15271 })
15272 .collect();
15273 let frame = Frame::PushCommit {
15274 client_commit_id: commit.client_commit_id.clone(),
15275 operations,
15276 };
15277 let frame_bytes = match byte_cap {
15278 Some(_) => {
15279 encode_message(&Message {
15280 wire_version: WIRE_VERSION,
15281 msg_kind: MsgKind::Request,
15282 frames: vec![header.clone(), frame.clone(), pull.clone()],
15283 })
15284 .len()
15285 - base_bytes
15286 }
15287 None => 0,
15288 };
15289 if let Some(cap) = byte_cap {
15293 if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
15294 deferred_commits = total_outbox - index;
15295 break;
15296 }
15297 }
15298 push_frames.push(frame);
15299 pushed_ids.push(commit.client_commit_id.clone());
15300 ops_in_request += commit.ops.len();
15301 bytes_in_request += frame_bytes;
15302 }
15303 #[cfg(feature = "bench-internals")]
15304 drop(outbox_phase);
15305 if let Some(cap) = byte_cap {
15306 if fixed_bytes + bytes_in_request > cap {
15307 let blocked = if push_frames.is_empty() {
15310 None
15311 } else {
15312 self.outbox.first().map(|c| c.client_commit_id.as_str())
15313 };
15314 return Err(Self::push_capacity_outcome(
15315 "bytes",
15316 cap,
15317 fixed_bytes + bytes_in_request,
15318 blocked,
15319 ));
15320 }
15321 }
15322 let mut frames = vec![header];
15323 frames.extend(push_frames);
15324 frames.push(pull);
15325 frames.extend(sub_frames);
15326 let message = Message {
15327 wire_version: WIRE_VERSION,
15328 msg_kind: MsgKind::Request,
15329 frames,
15330 };
15331 Ok((
15332 message,
15333 RequestMeta {
15334 pushed_ids,
15335 fresh,
15336 accept,
15337 deferred_commits,
15338 },
15339 ))
15340 }
15341
15342 fn push_capacity_outcome(
15344 kind: &str,
15345 limit: usize,
15346 size: usize,
15347 client_commit_id: Option<&str>,
15348 ) -> Box<SyncOutcome> {
15349 let mut details = Map::new();
15350 details.insert("kind".to_owned(), Value::from(kind));
15351 details.insert("limit".to_owned(), Value::from(limit as u64));
15352 details.insert("size".to_owned(), Value::from(size as u64));
15353 if let Some(id) = client_commit_id {
15354 details.insert("clientCommitId".to_owned(), Value::from(id));
15355 }
15356 Box::new(SyncOutcome::Failed {
15357 error_code: "client.push_request_too_large".into(),
15358 message: "the push request exceeds a configured capacity limit".into(),
15359 details: Some(Value::Object(details)),
15360 })
15361 }
15362
15363 pub fn progress(&self) -> ProgressObserver {
15367 self.progress.clone()
15368 }
15369
15370 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
15371 let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
15372 Ok(prepared) => prepared,
15373 Err(outcome) => return *outcome,
15374 };
15375 let mut next = prepared;
15380 loop {
15381 match self.apply_sync_round(next.exchange(transport)) {
15382 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
15383 crate::AppliedSyncRound::Complete {
15384 outcome, controls, ..
15385 } => {
15386 if self.transport_enabled {
15387 for text in controls {
15388 let _ = transport.realtime_send(&text);
15389 }
15390 } else {
15391 self.disconnect_realtime(transport);
15392 }
15393 return outcome;
15394 }
15395 }
15396 }
15397 }
15398
15399 fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
15400 if let SyncOutcome::Failed {
15401 error_code,
15402 message,
15403 details,
15404 } = &mut outcome
15405 {
15406 if let Some(failure) = self.storage_failure.borrow().as_ref() {
15407 let code = failure
15408 .code
15409 .expect("retained storage failures are classified");
15410 *error_code = code.to_owned();
15411 *message = match code {
15412 "client.storage_busy" => "local SQLite storage is busy",
15413 "client.storage_full" => "local SQLite storage is full",
15414 "client.storage_io" => "local SQLite storage I/O failed",
15415 "client.storage_corrupt" => "local SQLite storage is corrupt",
15416 _ => unreachable!("unrecognized classified storage failure"),
15417 }
15418 .to_owned();
15419 *details = failure.details();
15420 }
15421 }
15422 let retry_delay_ms = self.round_retry_delay_ms;
15423 self.progress.update(|p| match &outcome {
15424 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
15425 if report.failed.is_empty() =>
15426 {
15427 p.state = ProgressState::Complete
15428 }
15429 SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
15430 p.state = ProgressState::Failed;
15431 p.error_code = Some("sync.scope_revoked".into());
15432 }
15433 SyncOutcome::Failed { error_code, .. } => {
15434 p.state = ProgressState::Failed;
15435 p.error_code = Some(Self::diagnostic_code(error_code));
15436 p.retry_delay_ms = retry_delay_ms;
15437 }
15438 SyncOutcome::RealtimeUnavailable { .. } => {
15439 p.state = ProgressState::Failed;
15440 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
15441 p.retry_delay_ms = retry_delay_ms;
15442 }
15443 });
15444 let completed_at_ms = self.clock_now_ms();
15445 self.last_round = Some(match &outcome {
15446 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
15447 status: "succeeded".to_owned(),
15448 started_at_ms,
15449 completed_at_ms,
15450 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15451 counters: Some(DiagnosticRoundCounters {
15452 pushed: report.pushed,
15453 applied: report.applied.len(),
15454 rejected: report.rejected.len(),
15455 retryable: report.retryable.len(),
15456 conflicts: report.conflicts,
15457 commits_applied: report.commits_applied,
15458 segment_rows_applied: report.segment_rows_applied,
15459 bootstrapping: report.bootstrapping.len(),
15460 resets: report.resets.len(),
15461 revoked: report.revoked.len(),
15462 failed: report.failed.len(),
15463 deferred_commits: report.deferred_commits,
15464 }),
15465 error_code: None,
15466 },
15467 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
15468 status: "failed".to_owned(),
15469 started_at_ms,
15470 completed_at_ms,
15471 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15472 counters: None,
15473 error_code: Some(Self::diagnostic_code(error_code)),
15474 },
15475 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
15476 status: "failed".to_owned(),
15477 started_at_ms,
15478 completed_at_ms,
15479 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15480 counters: None,
15481 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
15482 },
15483 });
15484 outcome
15485 }
15486
15487 pub fn prepare_sync_round(
15490 &mut self,
15491 url_capable: bool,
15492 ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
15493 if !self.transport_enabled {
15494 return Err(Box::new(SyncOutcome::Failed {
15495 error_code: "sync.offline".into(),
15496 message: "transport gate is closed".into(),
15497 details: None,
15498 }));
15499 }
15500 if self.active_round.is_some() {
15501 return Err(Box::new(SyncOutcome::Failed {
15502 error_code: "client.round_in_progress".into(),
15503 message: "a sync round is already in flight".into(),
15504 details: None,
15505 }));
15506 }
15507 self.storage_failure.borrow_mut().take();
15508 self.progress.start();
15509 self.round_retry_delay_ms = None;
15510 let started_at_ms = self.clock_now_ms();
15511 let prepared = (|| {
15512 if self.stopped {
15513 return Err(Box::new(SyncOutcome::Ok(SyncReport {
15514 schema_floor: self.schema_floor.clone(),
15515 ..SyncReport::default()
15516 })));
15517 }
15518 if self.security_preflight {
15519 return Err(Box::new(SyncOutcome::Failed {
15520 error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
15521 message: "security preflight must complete before syncing".into(),
15522 details: None,
15523 }));
15524 }
15525 self.set_sync_needed(false, false);
15526 self.drain_pending_evictions()
15527 .and_then(|_| self.drop_incompatible_outbox())
15528 .and_then(|_| self.drop_unencodable_outbox(true, None))
15529 .map_err(|message| {
15530 Box::new(SyncOutcome::Failed {
15531 error_code: "storage.failed".into(),
15532 message,
15533 details: None,
15534 })
15535 })?;
15536 let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
15537 self.pending_blob_uploads().map_err(|e| {
15538 Box::new(SyncOutcome::Failed {
15539 error_code: e.code,
15540 message: e.message,
15541 details: e.details,
15542 })
15543 })?
15544 } else {
15545 Vec::new()
15546 };
15547 if self.realtime_state != RealtimeState::Connected
15548 && self.realtime_policy == RealtimePolicy::Required
15549 {
15550 let retry_delay_ms = self.schedule_background_retry();
15551 self.realtime_retry_delay_ms = Some(retry_delay_ms);
15552 return Err(Box::new(SyncOutcome::RealtimeUnavailable {
15553 state: self.realtime_state,
15554 reason_code: self.realtime_reason_code.clone(),
15555 retry_delay_ms,
15556 }));
15557 }
15558 let (message, meta) = self.build_request(url_capable)?;
15559 let id = uuid::Uuid::new_v4();
15560 self.active_round = Some(id);
15561 Ok(crate::PreparedSyncRound {
15562 id,
15563 started_at_ms,
15564 message,
15565 meta,
15566 uploads: uploads.into(),
15567 realtime: self.realtime_state == RealtimeState::Connected,
15568 scopes: self
15569 .subs
15570 .iter()
15571 .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
15572 .collect(),
15573 #[cfg(feature = "bench-internals")]
15574 benchmark_phases: self.benchmark_phases.clone(),
15575 progress: self.progress.clone(),
15576 fixed_now: self.now_ms,
15577 round_deadline_at: None,
15578 })
15579 })();
15580 prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
15581 }
15582
15583 pub fn cancel_sync_round(&mut self) {
15586 self.active_round = None;
15587 }
15588
15589 pub fn apply_sync_round(
15592 &mut self,
15593 completed: crate::CompletedSyncRound,
15594 ) -> crate::AppliedSyncRound {
15595 let prepared = completed.prepared;
15596 if self.active_round != Some(prepared.id) || self.security_preflight {
15597 return crate::AppliedSyncRound::Complete {
15598 outcome: SyncOutcome::Failed {
15599 error_code: "client.round_cancelled".into(),
15600 message: "sync round belongs to a released client context".into(),
15601 details: None,
15602 },
15603 controls: Vec::new(),
15604 more: false,
15605 bootstrap_advanced: false,
15606 };
15607 }
15608 self.storage_failure.borrow_mut().take();
15609 let (response, mut downloads, transport_failed) = match completed.exchange {
15610 crate::round::ExchangeResult::Upload { id, result } => {
15611 let result = result.and_then(|()| {
15612 self.conn
15613 .execute(
15614 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
15615 rusqlite::params![id],
15616 )
15617 .map(|_| ())
15618 .map_err(|error| {
15619 TransportError::new(
15620 "client.failed",
15621 Self::sqlite_failure(&self.storage_failure, error),
15622 )
15623 })
15624 });
15625 match result {
15626 Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
15627 Err(TransportError {
15628 code,
15629 message,
15630 details,
15631 }) => {
15632 self.active_round = None;
15633 if Self::retryable_failure_code(&code) {
15634 self.schedule_background_retry();
15635 }
15636 let outcome = self.finish_sync_round(
15637 prepared.started_at_ms,
15638 SyncOutcome::Failed {
15639 error_code: code,
15640 message,
15641 details,
15642 },
15643 );
15644 return crate::AppliedSyncRound::Complete {
15645 outcome,
15646 controls: Vec::new(),
15647 more: false,
15648 bootstrap_advanced: false,
15649 };
15650 }
15651 }
15652 }
15653 crate::round::ExchangeResult::Reply {
15654 response,
15655 downloads,
15656 transport_failed,
15657 } => (response, downloads, transport_failed),
15658 };
15659 self.active_round = None;
15660 let before = self
15661 .subs
15662 .iter()
15663 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
15664 .collect::<Vec<_>>();
15665 let outcome = (|| {
15666 let response = match response {
15667 Ok(response) => response,
15668 Err(TransportError {
15669 code,
15670 message,
15671 details,
15672 }) => {
15673 if transport_failed {
15674 if prepared.realtime {
15675 self.set_realtime_state(RealtimeState::Lost, Some(&code));
15676 }
15677 self.record_lease_error(&code);
15678 }
15679 if Self::retryable_failure_code(&code) {
15680 let delay = self.schedule_background_retry();
15681 if prepared.realtime && transport_failed {
15682 self.realtime_retry_delay_ms = Some(delay);
15683 }
15684 }
15685 return SyncOutcome::Failed {
15686 error_code: code,
15687 message,
15688 details,
15689 };
15690 }
15691 };
15692 let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
15693 if let SyncOutcome::Ok(report) = &mut outcome {
15694 report.deferred_commits = prepared.meta.deferred_commits;
15695 }
15696 match &outcome {
15697 SyncOutcome::Ok(_) => self.reset_background_retry(),
15698 SyncOutcome::Failed { error_code, .. }
15699 if Self::retryable_failure_code(error_code) =>
15700 {
15701 self.schedule_background_retry();
15702 }
15703 _ => {}
15704 }
15705 if prepared.meta.deferred_commits > 0 {
15706 self.set_sync_needed(true, true);
15707 }
15708 self.previous_version_lifetime_check();
15709 outcome
15710 })();
15711 let more = self.transport_enabled
15712 && matches!(&outcome, SyncOutcome::Ok(report)
15713 if report.schema_floor.is_none()
15714 && (!report.bootstrapping.is_empty()
15715 || report.commits_applied > 0
15716 || report.segment_rows_applied > 0
15717 || !report.resets.is_empty()
15718 || report.deferred_commits > 0
15719 || self.sync_needed));
15720 let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
15721 && before
15722 != self
15723 .subs
15724 .iter()
15725 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
15726 .collect::<Vec<_>>();
15727 crate::AppliedSyncRound::Complete {
15728 outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
15729 controls: if self.transport_enabled {
15730 downloads.controls
15731 } else {
15732 Vec::new()
15733 },
15734 more,
15735 bootstrap_advanced,
15736 }
15737 }
15738
15739 pub fn sync_until_idle(
15740 &mut self,
15741 transport: &mut dyn Transport,
15742 max_rounds: Option<u32>,
15743 ) -> SyncOutcome {
15744 if max_rounds == Some(0) {
15747 return SyncOutcome::Failed {
15748 details: None,
15749 error_code: "sync.invalid_request".into(),
15750 message: "maxRounds must be a positive integer".into(),
15751 };
15752 }
15753 let rounds = max_rounds.unwrap_or(20);
15754 let mut aggregate = SyncReport::default();
15755 let mut spent = 0;
15756 while spent < rounds {
15757 spent += 1;
15758 let before = self
15759 .subs
15760 .iter()
15761 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
15762 .collect::<Vec<_>>();
15763 match self.sync(transport) {
15764 SyncOutcome::Failed {
15765 error_code,
15766 message,
15767 details,
15768 } => {
15769 return SyncOutcome::Failed {
15770 details,
15771 error_code,
15772 message,
15773 };
15774 }
15775 outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
15776 SyncOutcome::BudgetExhausted(_) => {
15777 unreachable!("sync() never exhausts a round budget")
15778 }
15779 SyncOutcome::Ok(report) => {
15780 if max_rounds.is_none()
15781 && report.segment_rows_applied > 0
15782 && !report.bootstrapping.is_empty()
15783 && before
15784 != self
15785 .subs
15786 .iter()
15787 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
15788 .collect::<Vec<_>>()
15789 {
15790 spent = 0;
15791 }
15792 let stop = !self.transport_enabled() || report.schema_floor.is_some();
15795 aggregate.merge(&report);
15796 if stop {
15797 return SyncOutcome::Ok(aggregate);
15798 }
15799 let more = !report.bootstrapping.is_empty()
15805 || report.commits_applied > 0
15806 || report.segment_rows_applied > 0
15807 || !report.resets.is_empty()
15808 || report.deferred_commits > 0
15809 || self.sync_needed;
15810 if !more {
15811 return SyncOutcome::Ok(aggregate);
15812 }
15813 }
15814 }
15815 }
15816 SyncOutcome::BudgetExhausted(aggregate)
15819 }
15820
15821 fn process_response(
15822 &mut self,
15823 transport: &mut dyn Transport,
15824 response: Message,
15825 meta: &RequestMeta,
15826 ) -> SyncOutcome {
15827 #[cfg(feature = "bench-internals")]
15828 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
15829 let mut report = SyncReport {
15830 pushed: meta.pushed_ids.len() as u32,
15831 ..SyncReport::default()
15832 };
15833 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
15834 HashMap::new();
15835 let pushed_ids = meta
15836 .pushed_ids
15837 .iter()
15838 .map(String::as_str)
15839 .collect::<HashSet<_>>();
15840 let mut last_final_push_result_id: Option<String> = None;
15841 for frame in &response.frames {
15842 match frame {
15843 Frame::PushResultDetails {
15844 client_commit_id,
15845 entries,
15846 } => {
15847 let details = rejection_details_by_commit
15848 .entry(client_commit_id.clone())
15849 .or_default();
15850 for entry in entries {
15851 let parsed = match RejectionDetails::parse(&entry.details.0) {
15852 Ok(value) => value,
15853 Err(message) => {
15854 return SyncOutcome::Failed {
15855 details: None,
15856 error_code: "sync.invalid_request".to_owned(),
15857 message,
15858 };
15859 }
15860 };
15861 details.insert(entry.op_index, parsed);
15862 }
15863 }
15864 Frame::PushResult {
15865 client_commit_id,
15866 status,
15867 results,
15868 ..
15869 } if pushed_ids.contains(client_commit_id.as_str())
15870 && Self::push_result_is_final(*status, results) =>
15871 {
15872 last_final_push_result_id = Some(client_commit_id.clone());
15873 }
15874 _ => {}
15875 }
15876 }
15877 let mut frames = response.frames.into_iter().peekable();
15878 if response.wire_version < 2 {
15879 return SyncOutcome::Failed {
15880 details: None,
15881 error_code: "client.invalid_host_response".to_owned(),
15882 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
15883 };
15884 }
15885 match frames.next() {
15886 Some(Frame::RespHeader {
15887 required_schema_version,
15888 latest_schema_version,
15889 log_epoch,
15890 reset_required,
15891 }) => {
15892 let Some(log_epoch) = log_epoch else {
15893 return SyncOutcome::Failed {
15894 details: None,
15895 error_code: "client.invalid_host_response".to_owned(),
15896 message: "response header omits logEpoch".to_owned(),
15897 };
15898 };
15899 let Some(reset_required) = reset_required else {
15900 return SyncOutcome::Failed {
15901 details: None,
15902 error_code: "client.invalid_host_response".to_owned(),
15903 message: "response header omits resetRequired".to_owned(),
15904 };
15905 };
15906 if let Some(required) = required_schema_version {
15907 let floor = SchemaFloor {
15910 required_schema_version: Some(required),
15911 latest_schema_version,
15912 };
15913 self.set_schema_floor(Some(floor.clone()));
15914 report.schema_floor = Some(floor);
15915 return SyncOutcome::Ok(report);
15916 }
15917 if reset_required {
15918 if frames.next().is_some() {
15919 return SyncOutcome::Failed {
15920 details: None,
15921 error_code: "client.invalid_host_response".to_owned(),
15922 message: "log-epoch reset response contains body frames".to_owned(),
15923 };
15924 }
15925 match self.run_log_epoch_reset(&log_epoch) {
15926 Ok(resets) => {
15927 report.resets = resets;
15928 return SyncOutcome::Ok(report);
15929 }
15930 Err(message) => {
15931 return SyncOutcome::Failed {
15932 details: None,
15933 error_code: "sync.local_corrupt".to_owned(),
15934 message,
15935 };
15936 }
15937 }
15938 }
15939 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
15940 return SyncOutcome::Failed {
15941 details: None,
15942 error_code: "client.invalid_host_response".to_owned(),
15943 message: "server changed logEpoch without requiring a reset".to_owned(),
15944 };
15945 }
15946 }
15947 _ => {
15948 return SyncOutcome::Failed {
15949 details: None,
15950 error_code: "sync.invalid_request".to_owned(),
15951 message: "response does not start with RESP_HEADER".to_owned(),
15952 };
15953 }
15954 }
15955
15956 let mut failure: Option<(String, String)> = None;
15957 while let Some(frame) = frames.next() {
15958 match frame {
15959 frame @ Frame::PushResult { .. } => {
15960 let successful = matches!(
15961 &frame,
15962 Frame::PushResult {
15963 status: PushStatus::Applied | PushStatus::Cached,
15964 ..
15965 }
15966 );
15967 let mut results = vec![frame];
15968 if successful {
15969 while matches!(
15970 frames.peek(),
15971 Some(Frame::PushResult {
15972 status: PushStatus::Applied | PushStatus::Cached,
15973 ..
15974 })
15975 ) {
15976 if let Some(next) = frames.next() {
15977 results.push(next);
15978 }
15979 }
15980 }
15981 if self
15982 .handle_push_results(
15983 &results,
15984 &pushed_ids,
15985 &rejection_details_by_commit,
15986 &mut report,
15987 last_final_push_result_id.as_deref(),
15988 )
15989 .is_err()
15990 {
15991 failure = Some((
15992 "client.outcome_persistence_failed".to_owned(),
15993 "local commit outcome could not be persisted".to_owned(),
15994 ));
15995 break;
15996 }
15997 }
15998 Frame::PushResultDetails { .. } => {}
15999 Frame::SubStart {
16000 id,
16001 status,
16002 reason_code,
16003 effective_scopes,
16004 bootstrap: _,
16005 } => {
16006 let mut body = Vec::new();
16007 let mut sub_end: Option<(i64, Option<String>)> = None;
16008 for inner in frames.by_ref() {
16009 match inner {
16010 Frame::SubEnd {
16011 next_cursor,
16012 bootstrap_state,
16013 } => {
16014 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
16015 break;
16016 }
16017 Frame::Unknown { .. } => {}
16018 other => body.push(other),
16019 }
16020 }
16021 if let Err(SectionError::Abort(code, message)) = self.process_section(
16022 transport,
16023 &id,
16024 status,
16025 &reason_code,
16026 effective_scopes,
16027 body,
16028 sub_end,
16029 meta,
16030 &mut report,
16031 ) {
16032 failure = Some((code, message));
16033 break;
16034 }
16035 }
16036 Frame::Lease {
16037 lease_id,
16038 expires_at_ms,
16039 } => {
16040 self.set_lease_state(Some(LeaseState {
16043 lease_id: Some(lease_id),
16044 expires_at_ms: Some(expires_at_ms),
16045 error_code: None,
16046 }));
16047 }
16048 Frame::Error { code, message, .. } => {
16049 failure = Some((code, message));
16052 break;
16053 }
16054 Frame::Unknown { .. } => {}
16055 _ => {
16056 failure = Some((
16057 "sync.invalid_request".to_owned(),
16058 "unexpected frame in response".to_owned(),
16059 ));
16060 break;
16061 }
16062 }
16063 }
16064
16065 if let Some((error_code, message)) = failure {
16066 return SyncOutcome::Failed {
16067 details: None,
16068 error_code,
16069 message,
16070 };
16071 }
16072 if let Err(message) = self.rebuild_overlay_if_dirty() {
16075 return SyncOutcome::Failed {
16076 details: None,
16077 error_code: "storage.failed".into(),
16078 message,
16079 };
16080 }
16081 if let Err(message) = self.drain_pending_evictions() {
16084 return SyncOutcome::Failed {
16085 details: None,
16086 error_code: "storage.failed".into(),
16087 message,
16088 };
16089 }
16090 self.ack_after_pull(transport);
16091 if self.upgrading && report.bootstrapping.is_empty() {
16094 self.set_upgrading(false);
16095 }
16096 SyncOutcome::Ok(report)
16097 }
16098
16099 fn handle_push_results(
16102 &mut self,
16103 frames: &[Frame],
16104 pushed_ids: &HashSet<&str>,
16105 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
16106 report: &mut SyncReport,
16107 last_final_push_result_id: Option<&str>,
16108 ) -> Result<(), String> {
16109 self.begin_observation("syncular_push_results")?;
16110 let previous_failed = self.failed_commits.clone();
16111 let previous_sync_needed = self.sync_needed;
16112 let conflict_count = self.conflicts.len();
16113 let rejection_count = self.rejections.len();
16114 let applied_count = report.applied.len();
16115 let rejected_count = report.rejected.len();
16116 let retryable_count = report.retryable.len();
16117 let was_dirty = self.overlay_dirty.snapshot();
16118 let mut removed = Vec::new();
16119 let mut batch = ChangeAccumulator::default();
16120 let persisted = (|| {
16121 for frame in frames {
16122 let Frame::PushResult {
16123 client_commit_id,
16124 status,
16125 results,
16126 commit_seq,
16127 } = frame
16128 else {
16129 return Err("unexpected acknowledgement frame".to_owned());
16130 };
16131 if !pushed_ids.contains(client_commit_id.as_str()) {
16132 continue;
16133 }
16134 let Some(index) = self
16135 .outbox
16136 .iter()
16137 .position(|commit| commit.client_commit_id == *client_commit_id)
16138 else {
16139 continue;
16140 };
16141 let status = *status;
16142 let rejection_details = rejection_details_by_commit.get(client_commit_id);
16143 let operations = self.outbox[index].ops.clone();
16144 match status {
16145 PushStatus::Applied | PushStatus::Cached => {
16146 let sequence =
16149 commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
16150 let affected: HashSet<_> = operations
16151 .iter()
16152 .map(|op| (op.table.as_str(), op.row_id.as_str()))
16153 .collect();
16154 let prior = self.acknowledged_operations(Some(&affected), None)?;
16155 for (op_index, operation) in operations.iter().enumerate() {
16156 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))
16157 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16158 if delivered {
16159 continue;
16160 }
16161 let table = self
16162 .schema
16163 .table(&operation.table)
16164 .ok_or_else(|| "sync.unknown_table".to_owned())?;
16165 let mut intended: Map<String, Value> = self
16166 .stored_row(table, &operation.row_id, true)?
16167 .map(|(row, _)| {
16168 table
16169 .columns
16170 .iter()
16171 .zip(&row)
16172 .map(|(column, value)| {
16173 (column.name.clone(), column_value_to_json(value))
16174 })
16175 .collect()
16176 })
16177 .unwrap_or_default();
16178 let mut scope_image = intended.clone();
16179 for op in prior
16180 .iter()
16181 .chain(
16182 self.failed_commits
16183 .iter()
16184 .flat_map(|(commit, _)| &commit.ops),
16185 )
16186 .chain(
16187 self.outbox
16188 .iter()
16189 .take(index)
16190 .flat_map(|commit| &commit.ops),
16191 )
16192 .chain(operations.iter().take(op_index + 1))
16193 {
16194 if op.table != operation.table || op.row_id != operation.row_id {
16195 continue;
16196 }
16197 if let Some(values) = &op.values {
16198 intended.extend(values.clone());
16199 } else {
16200 scope_image = intended.clone();
16201 intended.clear();
16202 }
16203 }
16204 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)",
16205 rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
16206 serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
16207 serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
16208 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16209 }
16210 let journal_results = results
16211 .iter()
16212 .map(|result| {
16213 let op_index = match result {
16214 OpResult::Applied { op_index }
16215 | OpResult::Conflict { op_index, .. }
16216 | OpResult::Error { op_index, .. } => *op_index,
16217 };
16218 CommitOperationOutcome::Applied { op_index }
16219 })
16220 .collect::<Vec<_>>();
16221 let outcome_status = if status == PushStatus::Applied {
16222 CommitOutcomeStatus::Applied
16223 } else {
16224 CommitOutcomeStatus::Cached
16225 };
16226 let persisted = self
16227 .persist_commit_outcome(
16228 client_commit_id,
16229 outcome_status,
16230 &journal_results,
16231 None,
16232 )
16233 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
16234 persisted?;
16235 for operation in &operations {
16236 self.overlay_dirty.table(&operation.table);
16237 }
16238 self.sync_needed = true;
16239 batch.status = true;
16240 batch.outcomes = true;
16241 }
16242 PushStatus::Rejected => {
16243 if results.iter().any(|result| {
16244 matches!(
16245 result,
16246 OpResult::Error {
16247 code,
16248 retryable: true,
16249 ..
16250 } if code == "sync.idempotency_cache_miss"
16251 )
16252 }) {
16253 report.retryable.push(client_commit_id.to_owned());
16256 continue;
16257 }
16258
16259 let mut journal_results = Vec::with_capacity(results.len());
16260 let mut conflicts = Vec::new();
16261 let mut rejections = Vec::new();
16262 for result in results {
16263 match result {
16264 OpResult::Applied { op_index } => {
16265 journal_results.push(CommitOperationOutcome::Applied {
16266 op_index: *op_index,
16267 });
16268 }
16269 OpResult::Conflict {
16270 op_index,
16271 code,
16272 message,
16273 server_version,
16274 server_row,
16275 conflict_columns: conflict_columns_bitmap,
16276 } => {
16277 let operation = operations
16278 .get(*op_index as usize)
16279 .map(CommitOperation::from);
16280 let (table, row_id) = operation
16281 .as_ref()
16282 .map(|op| (op.table.clone(), op.row_id.clone()))
16283 .unwrap_or_default();
16284 let server_row_json = self
16285 .schema
16286 .table(&table)
16287 .and_then(|t| {
16288 decode_row_bytes(t, server_row, &self.encryption)
16289 .ok()
16290 .map(|row| (t, row))
16291 })
16292 .map(|(t, row)| {
16293 let mut map = Map::new();
16294 for (i, column) in t.columns.iter().enumerate() {
16295 map.insert(
16296 column.name.clone(),
16297 column_value_to_json(
16298 row.get(i).unwrap_or(&None),
16299 ),
16300 );
16301 }
16302 map
16303 })
16304 .unwrap_or_default();
16305 let conflict_columns = self
16308 .schema
16309 .table(&table)
16310 .map(|t| {
16311 t.columns
16312 .iter()
16313 .enumerate()
16314 .filter(|(index, _)| {
16315 conflict_columns_bitmap
16316 .get(index / 8)
16317 .is_some_and(|byte| {
16318 byte & (1 << (index % 8)) != 0
16319 })
16320 })
16321 .map(|(_, column)| column.name.clone())
16322 .collect::<Vec<_>>()
16323 })
16324 .unwrap_or_default();
16325 let conflict = ConflictRecord {
16326 client_commit_id: client_commit_id.to_owned(),
16327 op_index: *op_index,
16328 table,
16329 row_id,
16330 code: code.clone(),
16331 message: message.clone(),
16332 server_version: *server_version,
16333 server_row: server_row_json,
16334 conflict_columns,
16335 operation,
16336 };
16337 journal_results.push(CommitOperationOutcome::Conflict {
16338 conflict: conflict.clone(),
16339 });
16340 conflicts.push(conflict);
16341 }
16342 OpResult::Error {
16343 op_index,
16344 code,
16345 message,
16346 retryable,
16347 } => {
16348 let rejection = RejectionRecord {
16349 client_commit_id: client_commit_id.to_owned(),
16350 op_index: *op_index,
16351 code: code.clone(),
16352 message: message.clone(),
16353 retryable: *retryable,
16354 details: rejection_details
16355 .and_then(|details| details.get(op_index))
16356 .cloned(),
16357 operation: operations
16358 .get(*op_index as usize)
16359 .map(CommitOperation::from),
16360 };
16361 journal_results.push(CommitOperationOutcome::Error {
16362 rejection: rejection.clone(),
16363 });
16364 rejections.push(rejection);
16365 }
16366 }
16367 }
16368 if self.retain_failed_commits {
16369 self.retain_failed_commit(client_commit_id, &operations)?;
16370 }
16371 let outcome_status = if conflicts.is_empty() {
16372 CommitOutcomeStatus::Rejected
16373 } else {
16374 CommitOutcomeStatus::Conflict
16375 };
16376 let persisted = self
16377 .persist_commit_outcome(
16378 client_commit_id,
16379 outcome_status,
16380 &journal_results,
16381 Some(&operations),
16382 )
16383 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
16384 persisted?;
16385 batch.conflicts = !conflicts.is_empty();
16386 batch.rejections = !rejections.is_empty();
16387 batch.status = true;
16388 batch.outcomes = true;
16389 self.conflicts.extend(conflicts);
16390 self.rejections.extend(rejections);
16391 for operation in &operations {
16392 self.overlay_dirty.table(&operation.table);
16393 }
16394 }
16395 }
16396 removed.push((index, self.outbox.remove(index)));
16397 if batch.status {
16398 for operation in &operations {
16399 if !self.record_row_scopes(
16400 &mut batch,
16401 &operation.table,
16402 &operation.row_id,
16403 false,
16404 ) {
16405 batch.table(&operation.table);
16406 }
16407 }
16408 }
16409 match status {
16410 PushStatus::Applied | PushStatus::Cached => {
16411 report.applied.push(client_commit_id.to_owned())
16412 }
16413 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
16414 }
16415 }
16416 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)) {
16417 self.prune_commit_outcomes()?;
16418 }
16419 self.rebuild_overlay_if_dirty()?;
16420 self.finish_observation("syncular_push_results", batch)
16421 })();
16422 if let Err(error) = persisted {
16423 self.rollback_observation("syncular_push_results");
16424 self.failed_commits = previous_failed;
16425 self.sync_needed = previous_sync_needed;
16426 for (index, commit) in removed.into_iter().rev() {
16427 self.outbox.insert(index, commit);
16428 }
16429 self.conflicts.truncate(conflict_count);
16430 self.rejections.truncate(rejection_count);
16431 self.overlay_dirty.restore(was_dirty.clone());
16432 report.applied.truncate(applied_count);
16433 report.rejected.truncate(rejected_count);
16434 report.retryable.truncate(retryable_count);
16435 return Err(error);
16436 }
16437 if !previous_sync_needed && self.sync_needed {
16438 self.sync_intent_queue.push_back(SyncIntent::Interactive);
16439 }
16440 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
16441 Ok(())
16442 }
16443
16444 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
16445 status != PushStatus::Rejected
16446 || !results.iter().any(|result| {
16447 matches!(
16448 result,
16449 OpResult::Error {
16450 code,
16451 retryable: true,
16452 ..
16453 } if code == "sync.idempotency_cache_miss"
16454 )
16455 })
16456 }
16457
16458 #[allow(clippy::too_many_arguments)]
16461 fn process_section(
16462 &mut self,
16463 transport: &mut dyn Transport,
16464 id: &str,
16465 status: SubStatus,
16466 reason_code: &str,
16467 effective_scopes: Vec<(String, Vec<String>)>,
16468 body: Vec<Frame>,
16469 sub_end: Option<(i64, Option<String>)>,
16470 meta: &RequestMeta,
16471 report: &mut SyncReport,
16472 ) -> Result<(), SectionError> {
16473 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
16474 return Ok(()); };
16476 if status != SubStatus::Active && sub_end.is_none() {
16477 return Err(SectionError::Abort(
16478 "sync.invalid_request".into(),
16479 "subscription section without SUB_END".into(),
16480 ));
16481 }
16482 match status {
16483 SubStatus::Revoked => {
16484 self.begin_observation("syncular_revocation")
16485 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
16486 let previous = self.subs[sub_index].clone();
16487 let was_dirty = self.overlay_dirty.snapshot();
16488 let previous_outbox = self.outbox.clone();
16489 let previous_failed = self.failed_commits.clone();
16490 let previous_rejections = self.rejections.clone();
16491 let previous_conflicts = self.conflicts.clone();
16492 let revoked_count = report.revoked.len();
16493 let failed_count = report.failed.len();
16494 let result = (|| {
16495 let mut batch = ChangeAccumulator::default();
16496 let registered = self.window_unit_by_sub_id(id);
16497 let (table, effective) = {
16499 let sub = &self.subs[sub_index];
16500 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
16501 };
16502 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
16503 SectionError::Abort(
16504 "sync.unknown_table".to_owned(),
16505 "revoked scope references an unknown table".to_owned(),
16506 )
16507 })?;
16508 let dropped = self
16509 .drop_failed_commits(|name, values| {
16510 name == table
16511 && !effective.is_empty()
16512 && effective.iter().all(|(variable, allowed)| {
16513 scoped_table
16514 .scope_column(variable)
16515 .and_then(|column| values.get(column))
16516 .and_then(Value::as_str)
16517 .is_some_and(|value| {
16518 allowed.iter().any(|allowed| allowed == value)
16519 })
16520 })
16521 })
16522 .map_err(|message| {
16523 SectionError::Abort("storage.failed".to_owned(), message)
16524 })?;
16525 for commit in dropped {
16526 batch.conflicts = true;
16527 batch.rejections = true;
16528 batch.outcomes = true;
16529 for operation in commit.ops {
16530 batch.table(&operation.table);
16531 }
16532 }
16533 let purged = self.purge_scope_rows(&table, &effective);
16534 match purged {
16535 Ok(()) => {
16536 self.record_scope_map(&mut batch, &table, &effective);
16537 let sub = &mut self.subs[sub_index];
16538 sub.state = SubState::Revoked;
16539 sub.reason_code = Some(if reason_code.is_empty() {
16540 "sync.scope_revoked".to_owned()
16541 } else {
16542 reason_code.to_owned()
16543 });
16544 report.revoked.push(id.to_owned());
16545 let doomed_effective = effective;
16546 let sub_table = table;
16547 self.persist_sub(&self.subs[sub_index].clone())
16548 .map_err(|message| {
16549 SectionError::Abort("storage.failed".into(), message)
16550 })?;
16551 let dropped = self
16552 .drop_doomed_outbox(&sub_table, &doomed_effective)
16553 .map_err(|message| {
16554 SectionError::Abort("storage.failed".to_owned(), message)
16555 })?;
16556 if dropped {
16557 batch.status = true;
16558 batch.rejections = true;
16559 batch.outcomes = true;
16560 }
16561 self.delete_unreferenced_cached_blobs().map_err(|message| {
16564 SectionError::Abort("storage.failed".into(), message)
16565 })?;
16566 }
16567 Err(()) => {
16568 let sub = &mut self.subs[sub_index];
16571 sub.state = SubState::Failed;
16572 sub.reason_code = Some("sync.scope_revoked".to_owned());
16573 report.failed.push(id.to_owned());
16574 self.persist_sub(&self.subs[sub_index].clone())
16575 .map_err(|message| {
16576 SectionError::Abort("storage.failed".into(), message)
16577 })?;
16578 }
16579 }
16580 if let Some((base_key, unit)) = registered {
16581 batch.window(&base_key, &self.subs[sub_index].table, &unit);
16582 }
16583 self.rebuild_overlay_if_dirty()
16584 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
16585 self.finish_observation("syncular_revocation", batch)
16586 .map_err(|message| {
16587 SectionError::Abort("storage.failed".to_owned(), message)
16588 })?;
16589 Ok(())
16590 })();
16591 if result.is_err() {
16592 self.rollback_observation("syncular_revocation");
16593 self.subs[sub_index] = previous;
16594 self.overlay_dirty.restore(was_dirty.clone());
16595 self.outbox = previous_outbox;
16596 self.failed_commits = previous_failed;
16597 self.rejections = previous_rejections;
16598 self.conflicts = previous_conflicts;
16599 report.revoked.truncate(revoked_count);
16600 report.failed.truncate(failed_count);
16601 }
16602 result
16603 }
16604 SubStatus::Reset => {
16605 self.begin_observation("syncular_reset")
16606 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
16607 let previous = self.subs[sub_index].clone();
16608 let was_dirty = self.overlay_dirty.snapshot();
16609 let reset_count = report.resets.len();
16610 let result = (|| {
16611 let mut batch = ChangeAccumulator::default();
16612 let registered = self.window_unit_by_sub_id(id);
16613 let sub = &mut self.subs[sub_index];
16616 sub.cursor = -1;
16617 sub.bootstrap_state = None;
16618 sub.reason_code = Some(if reason_code.is_empty() {
16619 "sync.cursor_expired".to_owned()
16620 } else {
16621 reason_code.to_owned()
16622 });
16623 report.resets.push(id.to_owned());
16624 self.persist_sub(&self.subs[sub_index].clone())
16625 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
16626 if let Some((base_key, unit)) = registered {
16627 batch.window(&base_key, &self.subs[sub_index].table, &unit);
16628 }
16629 self.finish_observation("syncular_reset", batch)
16630 .map_err(|message| {
16631 SectionError::Abort("storage.failed".to_owned(), message)
16632 })?;
16633 Ok(())
16634 })();
16635 if result.is_err() {
16636 self.rollback_observation("syncular_reset");
16637 self.subs[sub_index] = previous;
16638 self.overlay_dirty.restore(was_dirty.clone());
16639 report.resets.truncate(reset_count);
16640 }
16641 result
16642 }
16643 SubStatus::Active => {
16644 let fresh = meta
16645 .fresh
16646 .iter()
16647 .find(|(fid, _)| fid == id)
16648 .map(|(_, fresh)| *fresh)
16649 .unwrap_or(false);
16650 let previous = self.subs[sub_index].clone();
16651 self.subs[sub_index].effective = Some(effective_scopes);
16652 let outcome = self
16653 .apply_section_body(transport, sub_index, body, fresh, meta, report)
16654 .and_then(|()| {
16655 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
16656 SectionError::Abort(
16657 "sync.invalid_request".into(),
16658 "subscription section without SUB_END".into(),
16659 )
16660 })?;
16661 self.apply_sub_end(sub_index, cursor, bootstrap_state)
16662 .map_err(|message| {
16663 SectionError::Abort("storage.failed".into(), message)
16664 })
16665 });
16666 match outcome {
16667 Ok(()) => {
16668 if self.subs[sub_index].bootstrap_state.is_some() {
16669 report.bootstrapping.push(id.to_owned());
16670 }
16671 Ok(())
16672 }
16673 Err(SectionError::FailClosed) => {
16674 self.subs[sub_index] = previous.clone();
16675 self.begin_observation("syncular_section_failure")
16676 .map_err(|message| {
16677 SectionError::Abort("storage.failed".into(), message)
16678 })?;
16679 let persisted = (|| {
16680 let mut batch = ChangeAccumulator::default();
16681 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
16682 batch.window(&base_key, &self.subs[sub_index].table, &unit);
16683 }
16684 self.subs[sub_index].state = SubState::Failed;
16685 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
16686 self.persist_sub(&self.subs[sub_index])?;
16687 self.finish_observation("syncular_section_failure", batch)
16688 })();
16689 if let Err(message) = persisted {
16690 self.rollback_observation("syncular_section_failure");
16691 self.subs[sub_index] = previous;
16692 return Err(SectionError::Abort("storage.failed".into(), message));
16693 }
16694 report.failed.push(id.to_owned());
16695 Ok(())
16696 }
16697 Err(error) => {
16698 self.subs[sub_index] = previous;
16701 Err(error)
16702 }
16703 }
16704 }
16705 }
16706 }
16707
16708 fn apply_sub_end(
16709 &mut self,
16710 sub_index: usize,
16711 cursor: i64,
16712 bootstrap_state: Option<String>,
16713 ) -> Result<(), String> {
16714 #[cfg(feature = "bench-internals")]
16715 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
16716 let previous = self.subs[sub_index].clone();
16717 self.begin_observation("syncular_sub_end")?;
16718 let persisted = (|| {
16719 let mut batch = ChangeAccumulator::default();
16720 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
16721 && cursor >= 0
16722 && bootstrap_state.is_none();
16723 if completed {
16724 let table = self
16725 .schema
16726 .table(&previous.table)
16727 .ok_or_else(|| "sync.unknown_table".to_owned())?;
16728 let effective = previous.effective.as_deref().unwrap_or(&[]);
16729 let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
16730 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16731 let rows = statement
16732 .query_map(rusqlite::params![previous.table, cursor], |row| {
16733 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
16734 })
16735 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16736 let mut covered = HashSet::new();
16737 for row in rows {
16738 let (id, intent) =
16739 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16740 let values: Map<String, Value> = serde_json::from_str(&intent)
16741 .map_err(|_| "sync.local_corrupt".to_owned())?;
16742 if !effective.is_empty()
16743 && effective.iter().all(|(variable, allowed)| {
16744 table
16745 .scope_column(variable)
16746 .and_then(|column| values.get(column))
16747 .is_some_and(|value| {
16748 allowed.contains(
16749 &value
16750 .as_str()
16751 .map_or_else(|| value.to_string(), str::to_owned),
16752 )
16753 })
16754 })
16755 {
16756 covered.insert(id);
16757 }
16758 }
16759 drop(statement);
16760 for id in covered {
16761 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
16762 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16763 self.overlay_dirty.table(&previous.table);
16764 batch.table(&previous.table);
16765 }
16766 self.rebuild_overlay_if_dirty()?;
16767 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
16768 batch.window(&base_key, &previous.table, &unit);
16769 }
16770 }
16771 let sub = &mut self.subs[sub_index];
16772 sub.cursor = cursor;
16773 sub.bootstrap_state = bootstrap_state;
16774 sub.reason_code = None;
16777 sub.synced_once = true;
16778 self.persist_sub(&self.subs[sub_index])?;
16779 self.finish_observation("syncular_sub_end", batch)
16780 })();
16781 if persisted.is_err() {
16782 self.rollback_observation("syncular_sub_end");
16783 self.subs[sub_index] = previous;
16784 }
16785 persisted
16786 }
16787
16788 fn apply_section_body(
16789 &mut self,
16790 transport: &mut dyn Transport,
16791 sub_index: usize,
16792 body: Vec<Frame>,
16793 fresh: bool,
16794 meta: &RequestMeta,
16795 report: &mut SyncReport,
16796 ) -> Result<(), SectionError> {
16797 let mut saw_segment = false;
16798 for frame in body {
16799 match frame {
16800 Frame::Commit {
16801 tables,
16802 changes,
16803 commit_seq,
16804 ..
16805 } => {
16806 self.apply_commit_frame(&tables, &changes, Some(commit_seq))
16807 .map_err(|(c, m)| SectionError::Abort(c, m))?;
16808 report.commits_applied += 1;
16809 }
16810 Frame::SegmentInline { payload } => {
16811 let segment = decode_rows_segment(&payload)
16812 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
16813 self.progress.update(|p| {
16814 p.phase = ProgressPhase::Import;
16815 p.subscription_id = Some(self.subs[sub_index].id.clone());
16816 p.table = Some(self.subs[sub_index].table.clone());
16817 p.segment_id = None;
16818 p.bytes_received = payload.len() as u64;
16819 p.bytes_total = Some(payload.len() as u64);
16820 p.rows_processed = 0;
16821 p.rows_total =
16822 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
16823 });
16824 let first = !saw_segment;
16825 saw_segment = true;
16826 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
16827 report.segment_rows_applied += applied;
16828 }
16829 Frame::SegmentRef {
16830 segment_id,
16831 byte_length,
16832 media_type,
16833 table,
16834 row_count,
16835 as_of_commit_seq,
16836 scope_digest,
16837 row_cursor,
16838 next_row_cursor,
16839 url,
16840 url_expires_at_ms,
16841 ..
16842 } => {
16843 let advertised = match media_type {
16846 MediaType::Rows => {
16847 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
16848 || meta.accept & ACCEPT_INLINE_ROWS != 0
16849 }
16850 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
16851 };
16852 if !advertised {
16853 return Err(SectionError::Abort(
16854 "sync.invalid_request".to_owned(),
16855 format!(
16856 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
16857 media_type.name()
16858 ),
16859 ));
16860 }
16861 self.progress.update(|p| {
16862 p.phase = ProgressPhase::Download;
16863 p.subscription_id = Some(self.subs[sub_index].id.clone());
16864 p.table = Some(table.clone());
16865 p.segment_id = Some(segment_id.clone());
16866 p.bytes_received = 0;
16867 p.bytes_total = u64::try_from(byte_length).ok();
16868 p.rows_processed = 0;
16869 p.rows_total = u64::try_from(row_count).ok();
16870 });
16871 let observer = self.progress.clone();
16872 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
16873 let bytes = if let Some(url) = url {
16874 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
16879 return Err(SectionError::Abort(
16880 "sync.invalid_request".to_owned(),
16881 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
16882 .to_owned(),
16883 ));
16884 }
16885 if url_expires_at_ms.is_some_and(|exp| {
16887 exp <= transport
16888 .segment_fetch_started_at(&url)
16889 .unwrap_or_else(|| self.clock_now_ms())
16890 }) {
16891 return Err(SectionError::Abort(
16892 "sync.segment_expired".to_owned(),
16893 format!(
16894 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
16895 ),
16896 ));
16897 }
16898 transport
16899 .fetch_url(&url, &mut on_progress)
16900 .map_err(|e| SectionError::Abort(e.code, e.message))?
16901 } else {
16902 let requested_scopes_json =
16903 canonical_scope_json(&self.subs[sub_index].requested);
16904 transport
16905 .download_segment(
16906 &SegmentRequest {
16907 segment_id: segment_id.clone(),
16908 table,
16909 requested_scopes_json,
16910 },
16911 &mut on_progress,
16912 )
16913 .map_err(|e| SectionError::Abort(e.code, e.message))?
16914 };
16915 on_progress(bytes.len() as u64);
16917 let digest = Sha256::digest(&bytes);
16918 let expected = segment_id
16919 .strip_prefix("sha256:")
16920 .unwrap_or(segment_id.as_str());
16921 if bytes_to_hex(&digest) != expected {
16922 self.schedule_background_retry();
16924 return Err(SectionError::Abort(
16925 "sync.invalid_request".to_owned(),
16926 "segment bytes do not match the content address (§5.1)".to_owned(),
16927 ));
16928 }
16929 self.progress.update(|p| p.phase = ProgressPhase::Import);
16930 if media_type == MediaType::Sqlite {
16931 if row_cursor.is_some() || next_row_cursor.is_some() {
16934 return Err(SectionError::Abort(
16935 "sync.invalid_request".to_owned(),
16936 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
16937 .to_owned(),
16938 ));
16939 }
16940 let first = !saw_segment;
16941 saw_segment = true;
16942 let applied = self.apply_sqlite_segment(
16943 sub_index,
16944 &bytes,
16945 fresh && first,
16946 row_count,
16947 as_of_commit_seq,
16948 &scope_digest,
16949 )?;
16950 report.segment_rows_applied += applied;
16951 } else {
16952 let segment = decode_rows_segment(&bytes).map_err(|e| {
16953 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
16954 })?;
16955 let first = row_cursor.is_none();
16956 saw_segment = true;
16957 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
16958 report.segment_rows_applied += applied;
16959 }
16960 }
16961 Frame::Unknown { .. } => {}
16962 Frame::Error { code, message, .. } => {
16963 return Err(SectionError::Abort(code, message))
16964 }
16965 _ => {
16966 return Err(SectionError::Abort(
16967 "sync.invalid_request".to_owned(),
16968 "unexpected frame inside a subscription section".to_owned(),
16969 ));
16970 }
16971 }
16972 }
16973 Ok(())
16974 }
16975
16976 fn apply_commit_frame(
16978 &mut self,
16979 tables: &[String],
16980 changes: &[ssp2::model::Change],
16981 commit_seq: Option<i64>,
16982 ) -> Result<(), (String, String)> {
16983 #[cfg(feature = "bench-internals")]
16984 let _phase = self.benchmark_phases.start(Phase::CommitApply);
16985 let was_dirty = self.overlay_dirty.snapshot();
16986 self.begin_observation("syncular_commit")
16987 .map_err(|message| ("storage.failed".into(), message))?;
16988 let applied = (|| {
16989 let mut batch = ChangeAccumulator::default();
16990 self.record_commit_changes(&mut batch, tables, changes);
16991 if let Some(sequence) = commit_seq {
16992 for change in changes {
16993 let table = tables.get(change.table_index as usize).ok_or_else(|| {
16994 (
16995 "sync.invalid_request".to_owned(),
16996 "change tableIndex out of range".to_owned(),
16997 )
16998 })?;
16999 if self
17000 .outbox
17001 .iter()
17002 .flat_map(|commit| &commit.ops)
17003 .any(|op| op.table == *table && op.row_id == change.row_id)
17004 {
17005 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])
17006 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
17007 }
17008 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])
17009 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
17010 }
17011 }
17012 self.apply_commit_changes(tables, changes)?;
17013 self.rebuild_overlay_if_dirty()
17014 .map_err(|message| ("storage.failed".into(), message))?;
17015 self.finish_observation("syncular_commit", batch)
17016 .map_err(|message| ("storage.failed".into(), message))
17017 })();
17018 if applied.is_err() {
17019 self.rollback_observation("syncular_commit");
17020 self.overlay_dirty.restore(was_dirty.clone());
17021 }
17022 applied
17023 }
17024
17025 fn apply_commit_changes(
17026 &mut self,
17027 tables: &[String],
17028 changes: &[ssp2::model::Change],
17029 ) -> Result<(), (String, String)> {
17030 let mirror_visible = !self.overlay_dirty.get()
17035 && self.failed_commits.is_empty()
17036 && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
17037 || changes.iter().all(|change| {
17038 tables
17039 .get(change.table_index as usize)
17040 .and_then(|name| self.schema.table(name))
17041 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
17042 }));
17043 for change in changes {
17044 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
17045 (
17046 "sync.invalid_request".to_owned(),
17047 "change tableIndex out of range".to_owned(),
17048 )
17049 })?;
17050 let table = self.schema.table(table_name).ok_or_else(|| {
17051 (
17052 "sync.schema_mismatch".to_owned(),
17053 format!("change targets unknown table {table_name:?}"),
17054 )
17055 })?;
17056 match change.op {
17057 Op::Upsert => {
17058 let payload = change.row.as_ref().ok_or_else(|| {
17059 (
17060 "sync.invalid_request".to_owned(),
17061 "upsert change without row payload".to_owned(),
17062 )
17063 })?;
17064 let row = {
17066 #[cfg(feature = "bench-internals")]
17067 let _phase = self.benchmark_phases.start(Phase::RowDecode);
17068 decode_row_bytes(table, payload, &self.encryption)
17069 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
17070 };
17071 let version = change.row_version.unwrap_or(0);
17072 let table_name = table.name.clone();
17073 self.write_base_row(&table_name, &row, version)
17074 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17075 if mirror_visible {
17076 self.write_row(&visible_table(&table_name), &table_name, &row, version)
17077 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17078 }
17079 }
17080 Op::Delete => {
17081 self.delete_base_row(table_name, &change.row_id)
17082 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17083 if mirror_visible {
17084 let sql = format!(
17085 "DELETE FROM {} WHERE {}",
17086 visible_table(table_name),
17087 row_id_predicate(table)
17088 );
17089 self.conn
17090 .prepare_cached(&sql)
17091 .and_then(|mut statement| {
17092 statement.execute(rusqlite::params![change.row_id])
17093 })
17094 .map_err(|error| {
17095 ("sync.invalid_request".to_owned(), error.to_string())
17096 })?;
17097 }
17098 }
17099 }
17100 }
17101 if mirror_visible {
17102 {
17103 let changed_rows: HashSet<(&str, &str)> = changes
17104 .iter()
17105 .map(|change| {
17106 (
17107 tables[change.table_index as usize].as_str(),
17108 change.row_id.as_str(),
17109 )
17110 })
17111 .collect();
17112 let acknowledged = self
17113 .acknowledged_operations(Some(&changed_rows), None)
17114 .map_err(|error| ("storage.failed".to_owned(), error))?;
17115 self.apply_outbox_ops(&acknowledged)
17116 .map_err(|message| ("storage.failed".into(), message))?;
17117 self.apply_outbox_ops(
17118 self.outbox
17119 .iter()
17120 .flat_map(|commit| &commit.ops)
17121 .filter(|op| {
17122 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
17123 }),
17124 )
17125 .map_err(|message| ("storage.failed".into(), message))?;
17126 }
17127 self.overlay_dirty.set(false);
17128 }
17129 Ok(())
17130 }
17131
17132 fn apply_segment(
17137 &mut self,
17138 sub_index: usize,
17139 segment: &RowsSegment,
17140 first_fresh_page: bool,
17141 ) -> Result<u32, SectionError> {
17142 let (sub_table, effective) = {
17143 let sub = &self.subs[sub_index];
17144 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17145 };
17146 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
17147 SectionError::Abort(
17148 "sync.schema_mismatch".to_owned(),
17149 format!("subscription table {sub_table:?} is not in the client schema"),
17150 )
17151 })?;
17152 let matches = segment.table == table.name
17157 && segment.schema_version == self.schema.version
17158 && segment.columns.len() == table.wire_columns.len()
17159 && segment
17160 .columns
17161 .iter()
17162 .zip(table.wire_columns.iter())
17163 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
17164 if !matches {
17165 return Err(SectionError::Abort(
17166 "sync.schema_mismatch".to_owned(),
17167 "segment column table does not match the generated schema (§5.2)".to_owned(),
17168 ));
17169 }
17170 let mut applied = 0u32;
17171 let empty = Vec::new();
17172 let blocks = if segment.blocks.is_empty() {
17173 std::slice::from_ref(&empty)
17174 } else {
17175 &segment.blocks
17176 };
17177 for (index, block) in blocks.iter().enumerate() {
17178 let clear = first_fresh_page && index == 0;
17179 if !clear && block.is_empty() {
17180 continue;
17181 }
17182 let was_dirty = self.overlay_dirty.snapshot();
17183 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
17184 && self
17185 .outbox
17186 .iter()
17187 .flat_map(|commit| &commit.ops)
17188 .any(|op| op.table == table.name);
17189 let mirror_visible = !reconcile_pending
17190 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
17191 self.begin_observation("syncular_segment_block")
17192 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17193 let outcome = (|| {
17194 self.rebuild_overlay_if_dirty()
17195 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17196 let mut changed_keys = Vec::new();
17197 let mut batch = ChangeAccumulator::default();
17198 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
17199 batch.table(&table.name);
17200 }
17201 if clear {
17202 self.purge_scope_rows(&table.name, &effective)
17203 .map_err(|()| SectionError::FailClosed)?;
17204 }
17205 for (row_index, row) in block.iter().enumerate() {
17206 let decrypted;
17207 let values = if table.has_encrypted_columns() {
17208 let mut values = row.values.clone();
17209 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
17210 .map_err(|message| {
17211 SectionError::Abort("client.decrypt_failed".into(), message)
17212 })?;
17213 decrypted = values;
17214 &decrypted
17215 } else {
17216 &row.values
17217 };
17218 self.write_base_row(&table.name, values, row.server_version)
17219 .map_err(|message| {
17220 SectionError::Abort("sync.invalid_request".into(), message)
17221 })?;
17222 if reconcile_pending {
17223 changed_keys.push(
17224 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
17225 |message| SectionError::Abort("storage.failed".into(), message),
17226 )?,
17227 );
17228 }
17229 if mirror_visible {
17230 self.write_row(
17231 &visible_table(&table.name),
17232 &table.name,
17233 values,
17234 row.server_version,
17235 )
17236 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17237 }
17238 let processed = u64::from(applied) + row_index as u64 + 1;
17239 if processed.is_multiple_of(1024) {
17240 self.progress.update(|p| p.rows_processed = processed);
17241 }
17242 }
17243 if reconcile_pending {
17244 self.reconcile_imported_rows(&table, &mut changed_keys)
17245 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17246 }
17247 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
17248 self.apply_outbox_ops(
17249 self.outbox
17250 .iter()
17251 .flat_map(|commit| &commit.ops)
17252 .filter(|op| op.table == table.name),
17253 )
17254 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17255 self.overlay_dirty.set(false);
17256 } else {
17257 self.rebuild_overlay_if_dirty()
17258 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17259 }
17260 self.finish_observation("syncular_segment_block", batch)
17261 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
17262 })();
17263 if let Err(error) = outcome {
17264 self.rollback_observation("syncular_segment_block");
17265 self.overlay_dirty.restore(was_dirty.clone());
17266 return Err(error);
17267 }
17268 applied += block.len() as u32;
17269 self.progress
17270 .update(|p| p.rows_processed = u64::from(applied));
17271 std::thread::yield_now();
17272 }
17273 self.progress
17274 .update(|p| p.rows_processed = u64::from(applied));
17275 Ok(applied)
17276 }
17277
17278 fn apply_sqlite_segment(
17286 &mut self,
17287 sub_index: usize,
17288 bytes: &[u8],
17289 first_fresh_page: bool,
17290 row_count: i64,
17291 as_of_commit_seq: i64,
17292 scope_digest: &str,
17293 ) -> Result<u32, SectionError> {
17294 let invalid = |detail: &str| {
17295 SectionError::Abort(
17296 "sync.invalid_request".to_owned(),
17297 format!("sqlite segment rejected: {detail} (§5.3)"),
17298 )
17299 };
17300 let (sub_table, effective) = {
17301 let sub = &self.subs[sub_index];
17302 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17303 };
17304 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
17305 SectionError::Abort(
17306 "sync.schema_mismatch".to_owned(),
17307 format!("subscription table {sub_table:?} is not in the client schema"),
17308 )
17309 })?;
17310
17311 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
17312 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
17313 let img = match rusqlite::Connection::open_with_flags(
17314 &path,
17315 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
17316 ) {
17317 Ok(conn) => conn,
17318 Err(_) => {
17319 let _ = std::fs::remove_file(&path);
17320 return Err(invalid("bytes do not open as a SQLite database"));
17321 }
17322 };
17323 let outcome = self.apply_sqlite_image(
17324 &img,
17325 &table,
17326 first_fresh_page,
17327 &effective,
17328 row_count,
17329 as_of_commit_seq,
17330 scope_digest,
17331 );
17332 drop(img);
17333 let _ = std::fs::remove_file(&path);
17334 outcome
17335 }
17336
17337 #[allow(clippy::too_many_arguments)]
17338 fn apply_sqlite_image(
17339 &mut self,
17340 img: &rusqlite::Connection,
17341 table: &crate::schema::TableSchema,
17342 first_fresh_page: bool,
17343 effective: &[(String, Vec<String>)],
17344 row_count: i64,
17345 as_of_commit_seq: i64,
17346 scope_digest: &str,
17347 ) -> Result<u32, SectionError> {
17348 let invalid = |detail: String| {
17349 SectionError::Abort(
17350 "sync.invalid_request".to_owned(),
17351 format!("sqlite segment rejected: {detail} (§5.3)"),
17352 )
17353 };
17354
17355 type MetaRow = (i64, String, i64, i64, String, i64, i64);
17358 let meta: MetaRow = img
17359 .query_row(
17360 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
17361 \"scopeDigest\", \"rowCount\",
17362 (SELECT count(*) FROM _syncular_segment)
17363 FROM _syncular_segment",
17364 [],
17365 |row| {
17366 Ok((
17367 row.get(0)?,
17368 row.get(1)?,
17369 row.get(2)?,
17370 row.get(3)?,
17371 row.get(4)?,
17372 row.get(5)?,
17373 row.get(6)?,
17374 ))
17375 },
17376 )
17377 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
17378 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
17379 if meta_count != 1 {
17380 return Err(invalid(format!(
17381 "_syncular_segment must contain exactly one row, found {meta_count}"
17382 )));
17383 }
17384 if format != 1 {
17385 return Err(invalid(format!("format {format}")));
17386 }
17387 if meta_table != table.name {
17388 return Err(invalid(format!("image table {meta_table:?}")));
17389 }
17390 if schema_version != i64::from(self.schema.version) {
17391 return Err(invalid(format!("schemaVersion {schema_version}")));
17392 }
17393 if pin != as_of_commit_seq {
17394 return Err(invalid(format!("asOfCommitSeq {pin}")));
17395 }
17396 if digest != scope_digest {
17397 return Err(invalid("scopeDigest mismatch".to_owned()));
17398 }
17399 if meta_rows != row_count {
17400 return Err(invalid(format!("rowCount {meta_rows}")));
17401 }
17402
17403 let mut names: Vec<String> = Vec::new();
17405 let mut primary_keys: Vec<String> = Vec::new();
17406 {
17407 let mut stmt = img
17408 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
17409 .map_err(|_| invalid("image data table missing".to_owned()))?;
17410 let mut rows = stmt
17411 .query([])
17412 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
17413 while let Some(row) = rows
17414 .next()
17415 .map_err(|_| invalid("image data table unreadable".to_owned()))?
17416 {
17417 if row
17418 .get::<_, i64>(5)
17419 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
17420 > 0
17421 {
17422 primary_keys.push(row.get(1).map_err(|error| {
17423 invalid(Self::sqlite_failure(&self.storage_failure, error))
17424 })?);
17425 }
17426 names.push(
17427 row.get::<_, String>(1)
17428 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
17429 );
17430 }
17431 }
17432 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
17433 expected.push("_syncular_version");
17434 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
17435 return Err(SectionError::Abort(
17436 "sync.schema_mismatch".to_owned(),
17437 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
17438 ));
17439 }
17440
17441 if primary_keys != vec![table.primary_key.clone()] {
17442 return Err(invalid(
17443 "image primary key does not match generated schema".into(),
17444 ));
17445 }
17446 let actual_count: i64 = img
17447 .query_row(
17448 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
17449 [],
17450 |row| row.get(0),
17451 )
17452 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17453 if actual_count != row_count {
17454 return Err(invalid("image row count does not match descriptor".into()));
17455 }
17456 let insert = self.insert_row_sql(&base_table(&table.name), table);
17457 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
17458 let column_list = names
17459 .iter()
17460 .map(|n| quote_ident(n))
17461 .collect::<Vec<_>>()
17462 .join(", ");
17463 let mut stmt = img
17464 .prepare(&format!(
17465 "SELECT {column_list} FROM {} ORDER BY {}",
17466 quote_ident(&table.name),
17467 quote_ident(&table.primary_key)
17468 ))
17469 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17470 let mut rows = stmt
17471 .query([])
17472 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17473 let mut applied = 0u32;
17474 loop {
17475 let clear = applied == 0 && first_fresh_page;
17476 let was_dirty = self.overlay_dirty.snapshot();
17477 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
17478 && self
17479 .outbox
17480 .iter()
17481 .flat_map(|commit| &commit.ops)
17482 .any(|op| op.table == table.name);
17483 let mirror_visible = !reconcile_pending
17484 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
17485 self.begin_observation("syncular_image_chunk")
17486 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17487 let result = (|| {
17488 self.rebuild_overlay_if_dirty()
17489 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17490 let mut changed_keys = Vec::new();
17491 let mut batch = ChangeAccumulator::default();
17492 if clear {
17493 if self.scoped_rows_exist(&table.name, effective) {
17494 batch.table(&table.name);
17495 }
17496 self.purge_scope_rows(&table.name, effective)
17497 .map_err(|()| SectionError::FailClosed)?;
17498 }
17499 let mut processed = 0u32;
17500 {
17501 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
17502 invalid(Self::sqlite_failure(&self.storage_failure, error))
17503 })?;
17504 let mut mirror = if mirror_visible {
17505 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
17506 invalid(Self::sqlite_failure(&self.storage_failure, error))
17507 })?)
17508 } else {
17509 None
17510 };
17511 for _ in 0..1024 {
17512 let Some(row) = rows.next().map_err(|error| {
17513 invalid(Self::sqlite_failure(&self.storage_failure, error))
17514 })?
17515 else {
17516 break;
17517 };
17518 for (i, column) in table.columns.iter().enumerate() {
17519 let cell = row.get_ref(i).map_err(|error| {
17520 invalid(Self::sqlite_failure(&self.storage_failure, error))
17521 })?;
17522 let param = image_cell_param(column, cell).map_err(invalid)?;
17523 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
17524 invalid(Self::sqlite_failure(&self.storage_failure, error))
17525 })?;
17526 if let Some(mirror) = &mut mirror {
17527 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
17528 invalid(Self::sqlite_failure(&self.storage_failure, error))
17529 })?;
17530 }
17531 }
17532 let version_index = table.columns.len();
17533 let version: i64 = row.get(version_index).map_err(|error| {
17534 invalid(Self::sqlite_failure(&self.storage_failure, error))
17535 })?;
17536 if version < 1 {
17537 return Err(invalid("image row version must be positive".into()));
17538 }
17539 ins.raw_bind_parameter(version_index + 1, version)
17540 .map_err(|error| {
17541 invalid(Self::sqlite_failure(&self.storage_failure, error))
17542 })?;
17543 ins.raw_execute().map_err(|error| {
17544 invalid(Self::sqlite_failure(&self.storage_failure, error))
17545 })?;
17546 if let Some(mirror) = &mut mirror {
17547 mirror
17548 .raw_bind_parameter(version_index + 1, version)
17549 .map_err(|error| {
17550 invalid(Self::sqlite_failure(&self.storage_failure, error))
17551 })?;
17552 mirror.raw_execute().map_err(|error| {
17553 invalid(Self::sqlite_failure(&self.storage_failure, error))
17554 })?;
17555 }
17556 if reconcile_pending {
17557 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
17558 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
17559 )?);
17560 }
17561 processed += 1;
17562 }
17563 }
17564 if processed > 0 {
17565 batch.table(&table.name);
17566 self.overlay_dirty.table(&table.name);
17567 }
17568 if reconcile_pending {
17569 self.reconcile_imported_rows(table, &mut changed_keys)
17570 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17571 }
17572 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
17573 self.apply_outbox_ops(
17574 self.outbox
17575 .iter()
17576 .flat_map(|commit| &commit.ops)
17577 .filter(|op| op.table == table.name),
17578 )
17579 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17580 self.overlay_dirty.set(false);
17581 } else {
17582 self.rebuild_overlay_if_dirty()
17583 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17584 }
17585 self.finish_observation("syncular_image_chunk", batch)
17586 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17587 Ok(processed)
17588 })();
17589 let processed = match result {
17590 Ok(processed) => processed,
17591 Err(error) => {
17592 self.rollback_observation("syncular_image_chunk");
17593 self.overlay_dirty.restore(was_dirty.clone());
17594 return Err(error);
17595 }
17596 };
17597 applied += processed;
17598 self.progress
17599 .update(|p| p.rows_processed = u64::from(applied));
17600 std::thread::yield_now();
17601 if i64::from(applied) == actual_count {
17602 break;
17603 }
17604 }
17605 Ok(applied)
17606 }
17607
17608 fn reconcile_imported_rows(
17612 &self,
17613 table: &TableSchema,
17614 keys: &mut Vec<SqlValue>,
17615 ) -> Result<(), String> {
17616 let visible = visible_table(&table.name);
17617 let base = base_table(&table.name);
17618 let pk = quote_ident(&table.primary_key);
17619 let predicate = row_id_predicate(table);
17620 let mut lookup = self.conn.prepare_cached(&format!(
17621 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
17622 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17623 let mut deleted = HashSet::new();
17624 for op in self
17625 .outbox
17626 .iter()
17627 .flat_map(|commit| &commit.ops)
17628 .filter(|op| op.table == table.name)
17629 {
17630 if op.upsert {
17631 if let Some(values) = &op.values {
17632 let key = json_to_column_value(
17633 &table.columns[table.pk_index],
17634 values.get(&table.primary_key),
17635 )?;
17636 keys.push(owned_sql_value(RowParam::Cell(&key))?);
17637 }
17638 } else if deleted.insert(&op.row_id) {
17639 keys.extend(
17640 lookup
17641 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
17642 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
17643 .collect::<Result<Vec<_>, _>>()
17644 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
17645 );
17646 }
17647 }
17648 for chunk in keys.chunks(400) {
17651 let holes = vec!["?"; chunk.len()].join(",");
17652 self.conn
17653 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
17654 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
17655 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17656 }
17657 let updates = table
17658 .columns
17659 .iter()
17660 .map(|column| column.name.as_str())
17661 .chain(std::iter::once("_syncular_version"))
17662 .filter(|name| *name != table.primary_key)
17663 .map(|name| {
17664 let name = quote_ident(name);
17665 format!("{name}=excluded.{name}")
17666 })
17667 .collect::<Vec<_>>()
17668 .join(",");
17669 for chunk in keys.chunks(400) {
17670 let holes = vec!["?"; chunk.len()].join(",");
17671 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
17672 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
17673 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17674 }
17675 Ok(())
17676 }
17677
17678 fn compile_local_data_purge(
17681 &self,
17682 input: &LocalDataPurgeInput,
17683 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
17684 let invalid = |message: String| format!("sync.invalid_request: {message}");
17685 if input.purge_id.is_empty()
17686 || input.purge_id.len() > 128
17687 || !is_local_operation_code_like(&input.purge_id)
17688 {
17689 return Err(invalid(
17690 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
17691 ));
17692 }
17693 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
17694 return Err(invalid(format!(
17695 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
17696 )));
17697 }
17698
17699 let mut deduplicated: BTreeMap<
17700 String,
17701 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
17702 > = BTreeMap::new();
17703 for target in &input.targets {
17704 let table = self.schema.table(&target.table).ok_or_else(|| {
17705 invalid(format!(
17706 "local purge names unknown table {:?}",
17707 target.table
17708 ))
17709 })?;
17710 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
17711 return Err(invalid(format!(
17712 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
17713 target.table
17714 )));
17715 }
17716 let mut selectors = Vec::with_capacity(target.selectors.len());
17717 let mut canonical_selectors = BTreeMap::new();
17718 for (column_name, raw_values) in &target.selectors {
17719 let Some((column_index, column)) = table
17720 .columns
17721 .iter()
17722 .enumerate()
17723 .find(|(_, column)| column.name == *column_name)
17724 else {
17725 return Err(invalid(format!(
17726 "local purge target {:?} names unknown column {:?}",
17727 target.table, column_name
17728 )));
17729 };
17730 let encrypted = table
17731 .encrypted_columns
17732 .iter()
17733 .any(|candidate| candidate.index == column_index);
17734 if column.ty != ColumnType::String || encrypted {
17735 return Err(invalid(format!(
17736 "local purge selector {:?}.{:?} must be a plaintext string column",
17737 target.table, column_name
17738 )));
17739 }
17740 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
17741 return Err(invalid(format!(
17742 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
17743 target.table, column_name
17744 )));
17745 }
17746 let mut values = raw_values.clone();
17747 values.sort();
17748 values.dedup();
17749 if values.iter().any(|value| {
17750 value.is_empty()
17751 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
17752 || !is_local_operation_code_like(value)
17753 }) {
17754 return Err(invalid(format!(
17755 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
17756 )));
17757 }
17758 selectors.push((column_name.clone(), values.clone()));
17759 canonical_selectors.insert(column_name.clone(), values);
17760 }
17761 let canonical = LocalDataPurgeTarget {
17762 table: target.table.clone(),
17763 selectors: canonical_selectors,
17764 };
17765 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
17766 deduplicated.insert(
17767 key,
17768 (
17769 CompiledLocalDataPurgeTarget {
17770 table: target.table.clone(),
17771 selectors,
17772 },
17773 canonical,
17774 ),
17775 );
17776 }
17777 let targets = deduplicated
17778 .values()
17779 .map(|(compiled, _)| compiled.clone())
17780 .collect::<Vec<_>>();
17781 let canonical = deduplicated
17782 .values()
17783 .map(|(_, target)| target.clone())
17784 .collect::<Vec<_>>();
17785 let canonical_plan =
17786 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
17787 Ok((targets, canonical_plan))
17788 }
17789
17790 fn local_purge_base_row_ids(
17791 &self,
17792 targets: &[CompiledLocalDataPurgeTarget],
17793 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
17794 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
17795 for target in targets {
17796 let table = self
17797 .schema
17798 .table(&target.table)
17799 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
17800 let mut clauses = Vec::with_capacity(target.selectors.len());
17801 let mut params = Vec::new();
17802 for (column, values) in &target.selectors {
17803 clauses.push(format!(
17804 "{} IN ({})",
17805 quote_ident(column),
17806 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
17807 ));
17808 params.extend(values.iter().cloned().map(SqlValue::Text));
17809 }
17810 let sql = format!(
17811 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
17812 quote_ident(&table.primary_key),
17813 base_table(&target.table),
17814 clauses.join(" AND ")
17815 );
17816 let mut statement = self
17817 .conn
17818 .prepare(&sql)
17819 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17820 let rows = statement
17821 .query_map(rusqlite::params_from_iter(params), |row| {
17822 let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
17823 render_row_id_json(Some(&value))
17824 .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
17825 })
17826 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17827 let ids = by_table.entry(target.table.clone()).or_default();
17828 for row in rows {
17829 ids.insert(
17830 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
17831 );
17832 }
17833 }
17834 Ok(by_table)
17835 }
17836
17837 fn local_purge_values_match(
17838 target: &CompiledLocalDataPurgeTarget,
17839 values: &Map<String, Value>,
17840 ) -> bool {
17841 target.selectors.iter().all(|(column, allowed)| {
17842 values
17843 .get(column)
17844 .and_then(Value::as_str)
17845 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
17846 })
17847 }
17848
17849 pub fn purge_local_data(
17853 &mut self,
17854 input: &LocalDataPurgeInput,
17855 ) -> Result<LocalDataPurgeResult, String> {
17856 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
17857 let meta_key = format!("localPurge:{}", input.purge_id);
17858 let applied_plan = self
17859 .conn
17860 .query_row(
17861 "SELECT value FROM _syncular_meta WHERE key = ?1",
17862 rusqlite::params![meta_key],
17863 |row| row.get::<_, String>(0),
17864 )
17865 .optional()
17866 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17867 if let Some(applied_plan) = applied_plan {
17868 if applied_plan != canonical_plan {
17869 return Err(format!(
17870 "sync.invalid_request: local purge id {:?} was already used with a different plan",
17871 input.purge_id
17872 ));
17873 }
17874 return Ok(LocalDataPurgeResult {
17875 already_applied: true,
17876 purged_rows: 0,
17877 dropped_commits: 0,
17878 });
17879 }
17880
17881 let _ = self.drop_previous_version();
17885
17886 let prior_outbox = self.outbox.clone();
17887 let prior_failed = self.failed_commits.clone();
17888 let prior_rejections = self.rejections.clone();
17889 let prior_conflicts = self.conflicts.clone();
17890 let prior_overlay_dirty = self.overlay_dirty.snapshot();
17891 self.begin_observation("syncular_local_purge")?;
17892 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
17893 let row_ids = self.local_purge_base_row_ids(&targets)?;
17894 let retained_dropped = self.drop_failed_commits(|table, values| {
17895 targets.iter().any(|target| {
17896 target.table == table && Self::local_purge_values_match(target, values)
17897 })
17898 })?;
17899 let doomed = self
17900 .outbox
17901 .iter()
17902 .filter(|commit| {
17903 commit.ops.iter().any(|operation| {
17904 let matching_targets = targets
17905 .iter()
17906 .filter(|target| target.table == operation.table)
17907 .collect::<Vec<_>>();
17908 if matching_targets.is_empty() {
17909 return false;
17910 }
17911 if row_ids
17912 .get(&operation.table)
17913 .is_some_and(|ids| ids.contains(&operation.row_id))
17914 {
17915 return true;
17916 }
17917 operation.values.as_ref().is_some_and(|values| {
17918 matching_targets
17919 .iter()
17920 .any(|target| Self::local_purge_values_match(target, values))
17921 })
17922 })
17923 })
17924 .cloned()
17925 .collect::<Vec<_>>();
17926 let doomed_ids = doomed
17927 .iter()
17928 .map(|commit| commit.client_commit_id.clone())
17929 .collect::<BTreeSet<_>>();
17930 let mut batch = ChangeAccumulator::default();
17931 for commit in &retained_dropped {
17932 batch.conflicts = true;
17933 batch.rejections = true;
17934 batch.outcomes = true;
17935 for operation in &commit.ops {
17936 batch.table(&operation.table);
17937 }
17938 }
17939 let mut rejections = Vec::new();
17940 for commit in &doomed {
17941 for operation in &commit.ops {
17942 batch.table(&operation.table);
17943 }
17944 let results = commit
17945 .ops
17946 .iter()
17947 .enumerate()
17948 .map(|(op_index, operation)| {
17949 let rejection = RejectionRecord {
17950 client_commit_id: commit.client_commit_id.clone(),
17951 op_index: op_index as i32,
17952 code: "client.local_data_purged".to_owned(),
17953 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
17954 retryable: false,
17955 details: None,
17956 operation: Some(CommitOperation::from(operation)),
17957 };
17958 rejections.push(rejection.clone());
17959 CommitOperationOutcome::Error { rejection }
17960 })
17961 .collect::<Vec<_>>();
17962 self.persist_commit_outcome(
17963 &commit.client_commit_id,
17964 CommitOutcomeStatus::Rejected,
17965 &results,
17966 Some(&commit.ops),
17967 )?;
17968 self.delete_outbox_persisted(&commit.client_commit_id)?;
17969 }
17970 if !doomed.is_empty() {
17971 self.outbox
17972 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
17973 self.rejections.extend(rejections);
17974 self.prune_commit_outcomes()?;
17975 batch.status = true;
17976 batch.rejections = true;
17977 batch.outcomes = true;
17978 }
17979
17980 let mut purged_rows = 0usize;
17981 for (table, ids) in &row_ids {
17982 if ids.is_empty() {
17983 continue;
17984 }
17985 batch.table(table);
17986 for row_id in ids {
17987 self.delete_base_row(table, row_id)?;
17988 purged_rows += 1;
17989 }
17990 }
17991 self.rebuild_overlay_if_dirty()?;
17992 self.delete_unreferenced_cached_blobs()?;
17993 self.conn
17994 .execute(
17995 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
17996 rusqlite::params![meta_key, canonical_plan],
17997 )
17998 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17999 Ok((
18000 batch,
18001 LocalDataPurgeResult {
18002 already_applied: false,
18003 purged_rows,
18004 dropped_commits: doomed.len(),
18005 },
18006 ))
18007 })();
18008 let (batch, result) = match applied {
18009 Ok(value) => value,
18010 Err(error) => {
18011 self.rollback_observation("syncular_local_purge");
18012 self.outbox = prior_outbox;
18013 self.failed_commits = prior_failed;
18014 self.rejections = prior_rejections;
18015 self.conflicts = prior_conflicts;
18016 self.overlay_dirty.restore(prior_overlay_dirty.clone());
18017 return Err(error);
18018 }
18019 };
18020 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
18021 self.rollback_observation("syncular_local_purge");
18022 self.outbox = prior_outbox;
18023 self.failed_commits = prior_failed;
18024 self.rejections = prior_rejections;
18025 self.conflicts = prior_conflicts;
18026 self.overlay_dirty.restore(prior_overlay_dirty.clone());
18027 return Err(error);
18028 }
18029 self.cancel_sync_round();
18030 Ok(result)
18031 }
18032
18033 pub fn rebootstrap_local_data(
18039 &mut self,
18040 input: &LocalDataRebootstrapInput,
18041 ) -> Result<LocalDataRebootstrapResult, String> {
18042 if input.rebootstrap_id.is_empty()
18043 || input.rebootstrap_id.len() > 128
18044 || !is_local_operation_code_like(&input.rebootstrap_id)
18045 {
18046 return Err(
18047 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
18048 .to_owned(),
18049 );
18050 }
18051 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
18052 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
18053 let (retained_commits, reset_subscriptions) =
18054 decode_local_rebootstrap_receipt(&persisted)?;
18055 return Ok(LocalDataRebootstrapResult {
18056 already_applied: true,
18057 retained_commits,
18058 reset_subscriptions,
18059 });
18060 }
18061 if self.stopped || self.schema_floor.is_some() {
18062 return Err(
18063 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
18064 .to_owned(),
18065 );
18066 }
18067
18068 let retained_commits = self.outbox.len();
18069 let reset_subscriptions = self.subs.len();
18070 let prior_subs = self.subs.clone();
18071 let prior_upgrading = self.upgrading;
18072 let prior_stopped = self.stopped;
18073 let prior_schema_floor = self.schema_floor.clone();
18074 let prior_overlay_dirty = self.overlay_dirty.snapshot();
18075 let prior_sync_needed = self.sync_needed;
18076 let prior_sync_intents = self.sync_intent_queue.clone();
18077 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
18078
18079 let prior_active_round = self.active_round;
18080 self.begin_observation("syncular_local_rebootstrap")?;
18081 let mut batch = ChangeAccumulator::default();
18082 let applied = (|| -> Result<(), String> {
18083 self.run_schema_reset_observed(&mut batch, false)?;
18084 self.conn
18085 .execute(
18086 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
18087 rusqlite::params![meta_key, receipt],
18088 )
18089 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18090 self.sync_needed = true;
18091 self.sync_intent_queue.push_back(SyncIntent::Interactive);
18092 batch.status = true;
18093 Ok(())
18094 })();
18095 if let Err(error) = applied {
18096 self.rollback_observation("syncular_local_rebootstrap");
18097 self.active_round = prior_active_round;
18098 self.subs = prior_subs;
18099 self.upgrading = prior_upgrading;
18100 self.stopped = prior_stopped;
18101 self.schema_floor = prior_schema_floor;
18102 self.overlay_dirty.restore(prior_overlay_dirty.clone());
18103 self.sync_needed = prior_sync_needed;
18104 self.sync_intent_queue = prior_sync_intents;
18105 return Err(error);
18106 }
18107 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
18108 self.rollback_observation("syncular_local_rebootstrap");
18109 self.active_round = prior_active_round;
18110 self.subs = prior_subs;
18111 self.upgrading = prior_upgrading;
18112 self.stopped = prior_stopped;
18113 self.schema_floor = prior_schema_floor;
18114 self.overlay_dirty.restore(prior_overlay_dirty.clone());
18115 self.sync_needed = prior_sync_needed;
18116 self.sync_intent_queue = prior_sync_intents;
18117 return Err(error);
18118 }
18119
18120 Ok(LocalDataRebootstrapResult {
18121 already_applied: false,
18122 retained_commits,
18123 reset_subscriptions,
18124 })
18125 }
18126
18127 fn purge_scope_rows(
18132 &mut self,
18133 table_name: &str,
18134 effective: &[(String, Vec<String>)],
18135 ) -> Result<(), ()> {
18136 if effective.is_empty() {
18137 return Ok(());
18138 }
18139 let table = self.schema.table(table_name).ok_or(())?.clone();
18140 let mut clauses = Vec::new();
18141 let mut params: Vec<SqlValue> = Vec::new();
18142 for (variable, values) in effective {
18143 let column = table.scope_column(variable).ok_or(())?;
18144 let placeholders: Vec<String> = values
18145 .iter()
18146 .map(|v| {
18147 params.push(SqlValue::Text(v.clone()));
18148 "?".to_owned()
18149 })
18150 .collect();
18151 clauses.push(format!(
18152 "{} IN ({})",
18153 quote_ident(column),
18154 placeholders.join(", ")
18155 ));
18156 }
18157 let sql = format!(
18158 "DELETE FROM {} WHERE {}",
18159 base_table(table_name),
18160 clauses.join(" AND ")
18161 );
18162 self.overlay_dirty.table(table_name);
18163 self.conn
18164 .execute(&sql, rusqlite::params_from_iter(¶ms))
18165 .map_err(|_| ())?;
18166 self.conn
18167 .execute(
18168 &format!(
18169 "DELETE FROM {} WHERE {}",
18170 visible_table(table_name),
18171 clauses.join(" AND ")
18172 ),
18173 rusqlite::params_from_iter(¶ms),
18174 )
18175 .map_err(|_| ())?;
18176 Ok(())
18177 }
18178
18179 fn drop_doomed_outbox(
18182 &mut self,
18183 table_name: &str,
18184 effective: &[(String, Vec<String>)],
18185 ) -> Result<bool, String> {
18186 if effective.is_empty() {
18187 return Ok(false);
18188 }
18189 let Some(table) = self.schema.table(table_name).cloned() else {
18190 return Ok(false);
18191 };
18192 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
18193 for (variable, values) in effective {
18194 match table.scope_column(variable) {
18195 Some(column) => mappings.push((column, values)),
18196 None => return Ok(false), }
18198 }
18199 let doomed: Vec<OutboxCommit> = self
18200 .outbox
18201 .iter()
18202 .filter(|commit| {
18203 commit.ops.iter().any(|op| {
18204 op.upsert
18205 && op.table == table_name
18206 && op.values.as_ref().is_some_and(|values| {
18207 mappings
18208 .iter()
18209 .all(|(column, allowed)| match values.get(*column) {
18210 Some(Value::String(s)) => allowed.contains(s),
18211 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
18212 Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
18213 _ => false,
18214 })
18215 })
18216 })
18217 })
18218 .cloned()
18219 .collect();
18220 if doomed.is_empty() {
18221 return Ok(false);
18222 }
18223 let mut rejections = Vec::new();
18224 for commit in &doomed {
18225 let results = commit
18226 .ops
18227 .iter()
18228 .enumerate()
18229 .map(|(op_index, operation)| {
18230 let rejection = RejectionRecord {
18231 client_commit_id: commit.client_commit_id.clone(),
18232 op_index: op_index as i32,
18233 code: "sync.scope_revoked".to_owned(),
18234 message: "the commit was dropped because its effective scope was revoked"
18235 .to_owned(),
18236 retryable: false,
18237 details: None,
18238 operation: Some(CommitOperation::from(operation)),
18239 };
18240 rejections.push(rejection.clone());
18241 CommitOperationOutcome::Error { rejection }
18242 })
18243 .collect::<Vec<_>>();
18244 self.persist_commit_outcome(
18245 &commit.client_commit_id,
18246 CommitOutcomeStatus::Rejected,
18247 &results,
18248 Some(&commit.ops),
18249 )?;
18250 self.delete_outbox_persisted(&commit.client_commit_id)?;
18251 }
18252 self.prune_commit_outcomes()?;
18253 let doomed_ids = doomed
18254 .iter()
18255 .map(|commit| commit.client_commit_id.as_str())
18256 .collect::<BTreeSet<_>>();
18257 self.outbox
18258 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
18259 self.rejections.extend(rejections);
18260 Ok(true)
18261 }
18262
18263 pub fn upload_blob(
18270 &mut self,
18271 bytes: &[u8],
18272 media_type: Option<String>,
18273 name: Option<String>,
18274 ) -> Result<Value, String> {
18275 let blob_id = blob_id_for(bytes);
18276 let now = self.clock_now_ms();
18277 let tx = self
18278 .conn
18279 .transaction()
18280 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18281 tx.execute(
18282 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
18283 ON CONFLICT(blob_id) DO NOTHING",
18284 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
18285 )
18286 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18287 tx.execute(
18288 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
18289 rusqlite::params![blob_id, media_type, now],
18290 )
18291 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18292 tx.commit()
18293 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18294 self.enforce_blob_cache_cap();
18295 let mut obj = Map::new();
18296 obj.insert("blobId".to_owned(), Value::from(blob_id));
18297 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
18298 if let Some(mt) = media_type {
18299 obj.insert("mediaType".to_owned(), Value::from(mt));
18300 }
18301 if let Some(n) = name {
18302 obj.insert("name".to_owned(), Value::from(n));
18303 }
18304 Ok(Value::Object(obj))
18305 }
18306
18307 pub fn fetch_blob_bytes(
18311 &mut self,
18312 transport: &mut dyn Transport,
18313 blob_id_or_ref: &str,
18314 ) -> Result<FetchedBlob, (String, String)> {
18315 let simple = |m: String| ("client.failed".to_owned(), m);
18316 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
18317 blob_id_or_ref.to_owned()
18318 } else {
18319 let value: Value = serde_json::from_str(blob_id_or_ref)
18320 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
18321 value
18322 .get("blobId")
18323 .and_then(Value::as_str)
18324 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
18325 .to_owned()
18326 };
18327 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
18328 return Ok(cached);
18329 }
18330 if !self.transport_enabled {
18331 return Err(("sync.offline".into(), "transport gate is closed".into()));
18332 }
18333 #[cfg(feature = "bench-internals")]
18339 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
18340 let bytes = match transport
18341 .blob_download(&blob_id)
18342 .map_err(|e| (e.code, e.message))?
18343 {
18344 BlobDownload::Bytes(bytes) => bytes,
18345 BlobDownload::Url {
18346 url,
18347 url_expires_at_ms,
18348 } => {
18349 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
18351 return Err((
18352 "sync.segment_expired".to_owned(),
18353 format!(
18354 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
18355 ),
18356 ));
18357 }
18358 transport
18359 .fetch_blob_url(&url)
18360 .map_err(|e| (e.code, e.message))?
18361 }
18362 };
18363 #[cfg(feature = "bench-internals")]
18364 drop(download_phase);
18365 #[cfg(feature = "bench-internals")]
18366 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
18367 if blob_id_for(&bytes) != blob_id {
18369 return Err(simple(format!(
18370 "blob content address mismatch for {blob_id}"
18371 )));
18372 }
18373 #[cfg(feature = "bench-internals")]
18374 drop(validate_phase);
18375 #[cfg(feature = "bench-internals")]
18376 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
18377 let now = self.clock_now_ms();
18378 self.conn
18379 .execute(
18380 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
18381 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
18382 )
18383 .map_err(|e| simple(e.to_string()))?;
18384 self.conn
18385 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
18386 .map_err(|e| simple(e.to_string()))?;
18387 self.enforce_blob_cache_cap();
18388 #[cfg(feature = "bench-internals")]
18389 drop(insert_phase);
18390 self.get_cached_blob_bytes(&blob_id)
18391 .map_err(simple)?
18392 .ok_or_else(|| simple("blob cache write failed".to_owned()))
18393 }
18394
18395 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
18396 #[cfg(feature = "bench-internals")]
18397 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
18398 let mut stmt = self
18399 .conn
18400 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
18401 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18402 let mut rows = stmt
18403 .query(rusqlite::params![blob_id])
18404 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18405 if let Some(row) = rows
18406 .next()
18407 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
18408 {
18409 let bytes: Vec<u8> = row
18410 .get(0)
18411 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18412 let byte_length: i64 = row
18413 .get(1)
18414 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18415 let media_type: Option<String> = row
18416 .get(2)
18417 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18418 return Ok(Some(FetchedBlob {
18419 blob_id: blob_id.to_owned(),
18420 byte_length,
18421 bytes,
18422 media_type,
18423 }));
18424 }
18425 Ok(None)
18426 }
18427
18428 fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
18430 let pending: Vec<(String, Option<String>)> = {
18431 let mut stmt = self
18432 .conn
18433 .prepare(
18434 "SELECT blob_id, media_type FROM (
18435 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
18436 UNION ALL
18437 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
18438 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
18439 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
18440 ) ORDER BY created_at_ms, blob_id",
18441 )
18442 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18443 let rows = stmt
18444 .query_map([], |row| {
18445 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
18446 })
18447 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18448 rows.collect::<Result<_, _>>()
18449 .map_err(|error| match error {
18450 rusqlite::Error::InvalidColumnType(..)
18451 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
18452 "sync.local_corrupt",
18453 "Pending blob upload metadata is invalid",
18454 ),
18455 _ => TransportError::new("client.failed", error.to_string()),
18456 })?
18457 };
18458 let mut uploads = Vec::new();
18459 for (blob_id, media_type) in pending {
18460 let bytes = (|| {
18461 let stored: Option<(SqlValue, SqlValue)> = self
18462 .conn
18463 .query_row(
18464 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
18465 rusqlite::params![blob_id],
18466 |row| Ok((row.get(0)?, row.get(1)?)),
18467 )
18468 .optional()
18469 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18470 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
18471 return Err(TransportError::new(
18472 "sync.local_corrupt",
18473 "Pending blob upload body is missing or corrupt",
18474 ));
18475 };
18476 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
18477 return Err(TransportError::new(
18478 "sync.local_corrupt",
18479 "Pending blob upload body is missing or corrupt",
18480 ));
18481 }
18482 Ok(bytes)
18483 })();
18484 let failed = bytes.is_err();
18485 uploads.push(crate::round::PendingUpload {
18486 id: blob_id,
18487 bytes,
18488 media_type,
18489 });
18490 if failed {
18491 break;
18492 }
18493 }
18494 Ok(uploads)
18495 }
18496
18497 #[cfg(test)]
18498 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
18499 for upload in self.pending_blob_uploads()? {
18500 self.upload_one(
18501 transport,
18502 &upload.id,
18503 &upload.bytes?,
18504 upload.media_type.as_deref(),
18505 )?;
18506 self.conn
18507 .execute(
18508 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
18509 rusqlite::params![upload.id],
18510 )
18511 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18512 }
18513 Ok(())
18514 }
18515
18516 #[cfg(test)]
18523 fn upload_one(
18524 &self,
18525 transport: &mut dyn Transport,
18526 blob_id: &str,
18527 bytes: &[u8],
18528 media_type: Option<&str>,
18529 ) -> Result<(), TransportError> {
18530 crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
18531 }
18532
18533 fn visible_blob_ids_sql(&self) -> String {
18534 let mut selects = Vec::new();
18535 for table in &self.schema.tables {
18536 for column in table
18537 .columns
18538 .iter()
18539 .filter(|column| column.ty == ColumnType::BlobRef)
18540 {
18541 let identifier = quote_ident(&column.name);
18542 selects.push(format!(
18543 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
18544 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
18545 AND json_type({identifier}, '$.blobId') = 'text'",
18546 quote_ident(&table.name),
18547 ));
18548 }
18549 }
18550 if selects.is_empty() {
18551 "SELECT NULL AS blob_id WHERE 0".to_owned()
18552 } else {
18553 selects.join(" UNION ")
18554 }
18555 }
18556
18557 fn enforce_blob_cache_cap(&self) {
18560 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
18561 return;
18562 };
18563 let mut total: i64 = self
18564 .conn
18565 .query_row(
18566 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
18567 [],
18568 |row| row.get(0),
18569 )
18570 .unwrap_or(0);
18571 if total <= max_bytes {
18572 return;
18573 }
18574 let query = format!(
18575 "SELECT blob_id, byte_length FROM _syncular_blobs
18576 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
18577 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
18578 AND blob_id NOT IN ({})
18579 ORDER BY created_at_ms ASC, blob_id ASC",
18580 self.visible_blob_ids_sql(),
18581 );
18582 let candidates: Vec<(String, i64)> = {
18583 let Ok(mut stmt) = self.conn.prepare(&query) else {
18584 return;
18585 };
18586 let Ok(rows) = stmt.query_map([], |row| {
18587 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
18588 }) else {
18589 return;
18590 };
18591 rows.filter_map(Result::ok).collect()
18592 };
18593 for (blob_id, byte_length) in candidates {
18594 if total <= max_bytes {
18595 break;
18596 }
18597 let _ = self.conn.execute(
18598 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
18599 rusqlite::params![blob_id],
18600 );
18601 total -= byte_length;
18602 }
18603 }
18604
18605 fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
18606 #[cfg(feature = "bench-internals")]
18607 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
18608 if !self.schema_has_blobs() {
18609 return Ok(());
18610 }
18611 self.rebuild_overlay_if_dirty()?;
18612 self.conn
18613 .execute(
18614 &format!(
18615 "DELETE FROM _syncular_blobs
18616 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
18617 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
18618 AND blob_id NOT IN ({})",
18619 self.visible_blob_ids_sql(),
18620 ),
18621 [],
18622 )
18623 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18624 Ok(())
18625 }
18626
18627 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
18632 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
18633 return sql.clone();
18634 }
18635 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
18636 columns.push(quote_ident("_syncular_version"));
18637 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
18638 let primary_key = quote_ident(&table.primary_key);
18639 let updates = columns
18640 .iter()
18641 .filter(|column| **column != primary_key)
18642 .map(|column| format!("{column}=excluded.{column}"))
18643 .collect::<Vec<_>>()
18644 .join(", ");
18645 let sql = format!(
18646 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
18647 columns.join(", "),
18648 placeholders.join(", ")
18649 );
18650 self.insert_sql
18651 .borrow_mut()
18652 .insert(full_table.to_owned(), sql.clone());
18653 sql
18654 }
18655
18656 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
18657 self.overlay_dirty.table(table_name);
18658 self.write_row(&base_table(table_name), table_name, row, version)
18659 }
18660
18661 fn write_row(
18662 &self,
18663 full_table: &str,
18664 table_name: &str,
18665 row: &Row,
18666 version: i64,
18667 ) -> Result<(), String> {
18668 #[cfg(feature = "bench-internals")]
18669 let _phase = self.benchmark_phases.start(Phase::RowWrite);
18670 let table = self
18671 .schema
18672 .table(table_name)
18673 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
18674 let sql = self.insert_row_sql(full_table, table);
18675 let mut stmt = self
18676 .conn
18677 .prepare_cached(&sql)
18678 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18679 let params = row
18680 .iter()
18681 .map(RowParam::Cell)
18682 .chain(std::iter::once(RowParam::Version(version)));
18683 stmt.execute(rusqlite::params_from_iter(params))
18684 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18685 Ok(())
18686 }
18687
18688 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
18689 let table = self
18690 .schema
18691 .table(table_name)
18692 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
18693 self.overlay_dirty.table(table_name);
18694 let sql = format!(
18695 "DELETE FROM {} WHERE {}",
18696 base_table(table_name),
18697 row_id_predicate(table)
18698 );
18699 let mut stmt = self
18700 .conn
18701 .prepare_cached(&sql)
18702 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18703 stmt.execute(rusqlite::params![row_id])
18704 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18705 Ok(())
18706 }
18707
18708 fn has_acknowledged_rows(&self) -> bool {
18709 self.conn
18710 .query_row(
18711 "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
18712 [],
18713 |row| row.get(0),
18714 )
18715 .expect("read acknowledged intent")
18716 }
18717
18718 fn acknowledged_operations(
18719 &self,
18720 rows: Option<&HashSet<(&str, &str)>>,
18721 tables: Option<&BTreeSet<&str>>,
18722 ) -> Result<Vec<OutboxOp>, String> {
18723 let keys = rows.map(|rows| {
18724 serde_json::to_string(
18725 &rows
18726 .iter()
18727 .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
18728 .collect::<Vec<_>>(),
18729 )
18730 .expect("row keys")
18731 });
18732 let mut statement = self
18733 .conn
18734 .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")
18735 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18736 let rows = statement
18737 .query_map(
18738 rusqlite::params![
18739 keys,
18740 tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
18741 ],
18742 |row| {
18743 #[cfg(test)]
18744 self.acknowledged_replay_count
18745 .set(self.acknowledged_replay_count.get() + 1);
18746 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
18747 },
18748 )
18749 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18750 rows.map(|row| {
18751 let (op, intent) =
18752 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18753 let op: CommitOperation =
18754 serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
18755 let table = self
18756 .schema
18757 .table(&op.table)
18758 .ok_or_else(|| "sync.unknown_table".to_owned())?;
18759 let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
18760 Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
18761 } else {
18762 op.values
18763 };
18764 Ok(OutboxOp {
18765 table: op.table,
18766 row_id: op.row_id,
18767 upsert: op.op == "upsert",
18768 base_version: op.base_version,
18769 values,
18770 })
18771 })
18772 .collect()
18773 }
18774
18775 fn drop_acknowledged_rows(
18776 &self,
18777 matches: impl Fn(&str, &Map<String, Value>) -> bool,
18778 ) -> Result<Vec<OutboxCommit>, String> {
18779 let mut statement = self
18780 .conn
18781 .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
18782 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18783 let rows = statement
18784 .query_map([], |row| {
18785 Ok((
18786 row.get::<_, String>(0)?,
18787 row.get::<_, String>(1)?,
18788 row.get::<_, String>(2)?,
18789 row.get::<_, String>(3)?,
18790 ))
18791 })
18792 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18793 let mut doomed = HashSet::new();
18794 for row in rows {
18795 let (id, table, intent, row_id) =
18796 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18797 let values =
18798 serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
18799 let base_matches = self
18800 .schema
18801 .table(&table)
18802 .map(|schema| self.stored_row(schema, &row_id, true))
18803 .transpose()?
18804 .flatten()
18805 .is_some_and(|(base, _)| {
18806 let schema = self.schema.table(&table).expect("acknowledged table");
18807 let values = schema
18808 .columns
18809 .iter()
18810 .zip(base.iter())
18811 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
18812 .collect();
18813 matches(&table, &values)
18814 });
18815 if matches(&table, &values) || base_matches {
18816 doomed.insert(id);
18817 }
18818 }
18819 let mut dropped = Vec::new();
18820 for id in doomed {
18821 let mut statement = self
18822 .conn
18823 .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
18824 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18825 let operations = statement
18826 .query_map([&id], |row| row.get::<_, String>(0))
18827 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18828 let mut ops = Vec::new();
18829 for operation in operations {
18830 let json = operation
18831 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18832 let op: CommitOperation =
18833 serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
18834 self.overlay_dirty.table(&op.table);
18835 ops.push(OutboxOp {
18836 table: op.table,
18837 row_id: op.row_id,
18838 upsert: op.op == "upsert",
18839 base_version: op.base_version,
18840 values: op.values,
18841 });
18842 }
18843 dropped.push(OutboxCommit {
18844 client_commit_id: id.clone(),
18845 ops,
18846 });
18847 self.conn
18848 .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
18849 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18850 }
18851 Ok(dropped)
18852 }
18853
18854 fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
18857 if self.overlay_dirty.get() {
18858 self.rebuild_overlay()?;
18859 }
18860 Ok(())
18861 }
18862
18863 fn rebuild_overlay(&mut self) -> Result<(), String> {
18867 #[cfg(feature = "bench-internals")]
18868 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
18869 #[cfg(test)]
18870 self.overlay_rebuild_count
18871 .set(self.overlay_rebuild_count.get() + 1);
18872 let dirty = self.overlay_dirty.snapshot();
18873 let tables: Vec<_> = self
18874 .schema
18875 .tables
18876 .iter()
18877 .filter(|table| {
18878 dirty
18879 .as_ref()
18880 .is_none_or(|names| names.is_empty() || names.contains(&table.name))
18881 })
18882 .cloned()
18883 .collect();
18884 let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
18885 self.begin_observation("syncular_overlay")?;
18886 let rebuilt = (|| {
18887 for table in &tables {
18888 for index in &table.fts_indexes {
18889 self.drop_fts_triggers(index)?;
18890 }
18891 let visible = visible_table(&table.name);
18892 let base = base_table(&table.name);
18893 self.conn
18894 .execute_batch(&format!(
18895 "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
18896 ))
18897 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18898 }
18899 let acknowledged = self.acknowledged_operations(None, Some(&names))?;
18900 self.apply_outbox_ops(
18901 acknowledged
18902 .iter()
18903 .filter(|op| names.contains(op.table.as_str())),
18904 )?;
18905 for (commit, initial) in &self.failed_commits {
18906 for operation in &commit.ops {
18907 if !names.contains(operation.table.as_str()) || !operation.upsert {
18908 continue;
18909 }
18910 if let Some(table) = self.schema.table(&operation.table) {
18911 if self.visible_row(table, &operation.row_id)?.is_none()
18912 && operation.values.as_ref().is_some_and(|values| {
18913 table
18914 .columns
18915 .iter()
18916 .all(|column| values.contains_key(&column.name))
18917 })
18918 {
18919 let key = serde_json::to_string(&(
18920 operation.table.clone(),
18921 operation.row_id.clone(),
18922 ))
18923 .expect("row key serialization");
18924 if let Some(Value::Object(values)) = initial.get(&key) {
18925 self.apply_outbox_ops(&[OutboxOp {
18926 values: Some(values.clone()),
18927 ..operation.clone()
18928 }])?;
18929 }
18930 }
18931 }
18932 }
18933 self.apply_outbox_ops(
18934 commit
18935 .ops
18936 .iter()
18937 .filter(|op| names.contains(op.table.as_str())),
18938 )?;
18939 }
18940 for commit in &self.outbox {
18941 self.apply_outbox_ops(
18942 commit
18943 .ops
18944 .iter()
18945 .filter(|op| names.contains(op.table.as_str())),
18946 )?;
18947 }
18948 for table in &tables {
18949 for index in &table.fts_indexes {
18950 self.rebuild_fts_projection(table, index)?;
18951 self.create_fts_triggers(table, index)?;
18952 }
18953 }
18954 self.conn
18955 .execute_batch("RELEASE syncular_overlay")
18956 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18957 Ok(())
18958 })();
18959 if rebuilt.is_err() {
18960 self.conn
18961 .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
18962 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18963 } else {
18964 self.overlay_dirty.set(false);
18965 }
18966 rebuilt
18967 }
18968
18969 fn apply_outbox_ops<'a>(
18974 &self,
18975 ops: impl IntoIterator<Item = &'a OutboxOp>,
18976 ) -> Result<(), String> {
18977 #[cfg(feature = "bench-internals")]
18978 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
18979 for op in ops {
18980 #[cfg(feature = "bench-internals")]
18981 if let Some(phase) = &mut phase {
18982 phase.unit();
18983 }
18984 if op.upsert {
18985 if let (Some(table), Some(values)) = (
18986 self.schema
18987 .table(&op.table)
18988 .filter(|table| table.indexes.iter().any(|index| index.unique)),
18989 op.values.as_ref(),
18990 ) {
18991 let mut intended: Map<String, Value> = self
18992 .visible_row(table, &op.row_id)?
18993 .map(|(row, _)| {
18994 table
18995 .columns
18996 .iter()
18997 .zip(&row)
18998 .map(|(column, value)| {
18999 (column.name.clone(), column_value_to_json(value))
19000 })
19001 .collect()
19002 })
19003 .unwrap_or_default();
19004 intended.extend(values.clone());
19005 if !self.unique_conflicts(table, &intended, false)?.is_empty() {
19006 continue;
19007 }
19008 }
19009 }
19010 self.apply_outbox_op(op)?;
19011 }
19012 Ok(())
19013 }
19014
19015 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
19016 let Some(table) = self.schema.table(&op.table) else {
19017 return Ok(());
19018 };
19019 let visible = visible_table(&table.name);
19020 if !op.upsert {
19021 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
19022 self.conn
19023 .prepare_cached(&sql)
19024 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
19025 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19026 return Ok(());
19027 }
19028 let Some(values) = op.values.as_ref() else {
19029 return Ok(());
19030 };
19031 let full = table
19036 .columns
19037 .iter()
19038 .all(|column| values.contains_key(&column.name));
19039 let local = self.visible_row(table, &op.row_id)?;
19040 if local.is_none() && !full {
19041 return Ok(());
19042 }
19043 let mut row: Row = Vec::with_capacity(table.columns.len());
19044 for (index, column) in table.columns.iter().enumerate() {
19045 let incoming = match values.get(&column.name) {
19046 Some(raw) => json_to_column_value(
19047 if raw.get("$bytes").is_some()
19048 && table
19049 .encrypted_columns
19050 .iter()
19051 .any(|encrypted| encrypted.index == index)
19052 {
19053 &table.wire_columns[index]
19054 } else {
19055 column
19056 },
19057 Some(raw),
19058 ),
19059 None => Ok(local
19060 .as_ref()
19061 .and_then(|(values, _)| values.get(index).cloned())
19062 .flatten()),
19063 };
19064 row.push(incoming.map_err(|_| {
19065 "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
19066 })?);
19067 }
19068 let version = local.as_ref().map_or(-1, |(_, version)| *version);
19069 self.write_row(&visible, &table.name, &row, version)
19070 .map_err(|error| {
19071 if error.starts_with("UNIQUE constraint failed:") {
19072 "sync.constraint_violation: local write violates a unique constraint".to_owned()
19073 } else {
19074 error
19075 }
19076 })
19077 }
19078
19079 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
19082 self.stored_row(table, row_id, false)
19083 }
19084
19085 fn stored_row(
19086 &self,
19087 table: &TableSchema,
19088 row_id: &str,
19089 base: bool,
19090 ) -> Result<Option<(Row, i64)>, String> {
19091 let sql = format!(
19092 "SELECT * FROM {} WHERE {}",
19093 if base {
19094 base_table(&table.name)
19095 } else {
19096 visible_table(&table.name)
19097 },
19098 row_id_predicate(table)
19099 );
19100 let mut stmt = self
19101 .conn
19102 .prepare_cached(&sql)
19103 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19104 let mut rows = stmt
19105 .query(rusqlite::params![row_id])
19106 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19107 let Some(row) = rows
19108 .next()
19109 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19110 else {
19111 return Ok(None);
19112 };
19113 let mut values = Vec::with_capacity(table.columns.len());
19114 for (index, column) in table.columns.iter().enumerate() {
19115 let raw = row
19116 .get_ref(index)
19117 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19118 if let rusqlite::types::ValueRef::Blob(bytes) = raw {
19119 if table
19120 .encrypted_columns
19121 .iter()
19122 .any(|encrypted| encrypted.index == index)
19123 {
19124 values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
19125 continue;
19126 }
19127 }
19128 let json = sql_ref_to_json(column, raw);
19129 values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
19130 "sync.local_corrupt: persisted local row value is invalid".to_owned()
19131 })?);
19132 }
19133 let version: i64 = row
19134 .get(table.columns.len())
19135 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19136 Ok(Some((values, version)))
19137 }
19138
19139 #[must_use]
19143 pub fn realtime_policy(&self) -> RealtimePolicy {
19144 self.realtime_policy
19145 }
19146
19147 #[must_use]
19149 pub fn realtime_state(&self) -> RealtimeState {
19150 self.realtime_state
19151 }
19152
19153 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
19156 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
19157 return Err(
19158 "sync.invalid_request: disconnect realtime before changing the realtime policy"
19159 .to_owned(),
19160 );
19161 }
19162 self.realtime_policy = policy;
19163 match policy {
19164 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
19165 RealtimePolicy::Required | RealtimePolicy::Optional
19166 if self.realtime_state == RealtimeState::Disabled =>
19167 {
19168 self.set_realtime_state(RealtimeState::Disconnected, None);
19169 }
19170 RealtimePolicy::Required | RealtimePolicy::Optional => {}
19171 }
19172 Ok(())
19173 }
19174
19175 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
19178 self.realtime_state = state;
19179 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
19180 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
19181 self.realtime_reason_code = None;
19182 self.realtime_retry_delay_ms = None;
19183 }
19184 }
19185
19186 #[must_use]
19187 fn realtime_connected(&self) -> bool {
19188 self.realtime_state == RealtimeState::Connected
19189 }
19190
19191 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
19192 if !self.transport_enabled {
19193 return Err("sync.offline: transport gate is closed".into());
19194 }
19195 if self.realtime_policy == RealtimePolicy::Off {
19196 return Err(
19197 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
19198 );
19199 }
19200 if self.realtime_connected() {
19201 return Ok(());
19202 }
19203 self.realtime_state = RealtimeState::Connecting;
19204 match transport.realtime_connect_for_client(&self.client_id) {
19205 Ok(()) => {
19206 self.set_realtime_state(RealtimeState::Connected, None);
19207 Ok(())
19208 }
19209 Err(error) => {
19210 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
19211 Err(format!("{}: {}", error.code, error.message))
19212 }
19213 }
19214 }
19215
19216 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
19217 self.cancel_sync_round();
19218 if self.realtime_state == RealtimeState::Disconnected
19221 || self.realtime_state == RealtimeState::Disabled
19222 {
19223 return;
19224 }
19225 let _ = transport.realtime_close();
19226 self.set_realtime_state(
19227 if self.realtime_policy == RealtimePolicy::Off {
19228 RealtimeState::Disabled
19229 } else {
19230 RealtimeState::Disconnected
19231 },
19232 None,
19233 );
19234 self.presence.clear(); }
19236
19237 pub fn set_presence(
19243 &mut self,
19244 transport: &mut dyn Transport,
19245 scope_key: &str,
19246 doc: Option<&Value>,
19247 ) -> Result<(), String> {
19248 if !self.transport_enabled {
19249 return Err("sync.offline: transport gate is closed".into());
19250 }
19251 if !self.realtime_connected() {
19252 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
19253 }
19254 let text = encode_presence_publish(scope_key, doc);
19255 transport
19256 .realtime_send(&text)
19257 .map_err(|e| format!("{}: {}", e.code, e.message))
19258 }
19259
19260 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
19262 self.presence
19263 .get(scope_key)
19264 .map(|peers| peers.values().cloned().collect())
19265 .unwrap_or_default()
19266 }
19267
19268 fn apply_presence(
19270 &mut self,
19271 scope_key: String,
19272 kind: Option<PresenceKind>,
19273 actor_id: Option<String>,
19274 client_id: Option<String>,
19275 doc: Option<Value>,
19276 error: Option<String>,
19277 ) {
19278 if error.is_some() {
19281 return;
19282 }
19283 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
19284 return;
19285 };
19286 let peer_key = format!("{actor_id} {client_id}");
19287 match kind {
19288 PresenceKind::Leave => {
19289 if let Some(peers) = self.presence.get_mut(&scope_key) {
19290 peers.remove(&peer_key);
19291 if peers.is_empty() {
19292 self.presence.remove(&scope_key);
19293 }
19294 }
19295 }
19296 _ => {
19297 let doc = match doc {
19298 Some(Value::Object(_)) => doc.unwrap(),
19299 _ => return,
19300 };
19301 self.presence.entry(scope_key).or_default().insert(
19302 peer_key,
19303 PresencePeer {
19304 actor_id,
19305 client_id,
19306 doc,
19307 },
19308 );
19309 }
19310 }
19311 }
19312
19313 pub fn on_realtime_text(&mut self, text: &str) {
19315 if self.stopped || !self.transport_enabled || self.security_preflight {
19316 return;
19317 }
19318 match parse_control(text) {
19319 Ok(ControlMessage::Hello { requires_sync, .. }) => {
19320 if requires_sync {
19321 self.set_sync_needed(true, true);
19323 }
19324 }
19325 Ok(ControlMessage::Presence {
19326 scope_key,
19327 kind,
19328 actor_id,
19329 client_id,
19330 doc,
19331 error,
19332 ..
19333 }) => {
19334 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
19335 }
19336 Ok(ControlMessage::Wake { .. }) => {
19337 self.set_sync_needed(true, true);
19339 }
19340 _ => {}
19341 }
19342 }
19343
19344 pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
19348 let mut transport = crate::round::DownloadResults::default();
19349 self.on_realtime_binary(&mut transport, bytes);
19350 transport.controls
19351 }
19352
19353 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
19354 if self.stopped || !self.transport_enabled || self.security_preflight {
19355 return;
19356 }
19357 #[cfg(feature = "bench-internals")]
19358 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
19359 let message = match decode_message(bytes) {
19360 Ok(m) if m.msg_kind == MsgKind::Response => m,
19361 _ => {
19362 self.set_sync_needed(true, true);
19363 return;
19364 }
19365 };
19366 #[cfg(feature = "bench-internals")]
19367 drop(decode_phase);
19368 let mut frames = message.frames.into_iter();
19369 let mut applied_cursor: Option<i64> = None;
19370 let mut any_covered = false;
19371 let mut dropped = false;
19372 while let Some(frame) = frames.next() {
19373 let Frame::SubStart {
19374 id,
19375 status,
19376 effective_scopes,
19377 ..
19378 } = frame
19379 else {
19380 continue;
19381 };
19382 let mut body = Vec::new();
19383 let mut next_cursor: Option<i64> = None;
19384 for inner in frames.by_ref() {
19385 match inner {
19386 Frame::SubEnd {
19387 next_cursor: nc, ..
19388 } => {
19389 next_cursor = Some(nc);
19390 break;
19391 }
19392 Frame::Unknown { .. } => {}
19393 other => body.push(other),
19394 }
19395 }
19396 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
19397 dropped = true;
19398 continue;
19399 };
19400 let sub = &self.subs[sub_index];
19401 if status != SubStatus::Active
19404 || sub.state != SubState::Active
19405 || sub.bootstrap_state.is_some()
19406 || !sub.synced_once
19407 {
19408 dropped = true;
19409 continue;
19410 }
19411 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
19412 any_covered = true;
19414 continue;
19415 }
19416 let previous_effective = self.subs[sub_index].effective.clone();
19417 let previous_cursor = self.subs[sub_index].cursor;
19418 self.subs[sub_index].effective = Some(effective_scopes);
19419 let mut failed = false;
19420 for inner in body {
19421 match inner {
19422 Frame::Commit {
19423 tables,
19424 changes,
19425 commit_seq,
19426 ..
19427 } => {
19428 if self
19429 .apply_commit_frame(&tables, &changes, Some(commit_seq))
19430 .is_err()
19431 {
19432 failed = true;
19433 break;
19434 }
19435 }
19436 _ => {
19437 failed = true;
19438 break;
19439 }
19440 }
19441 }
19442 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
19443 self.apply_sub_end(sub_index, cursor, None)
19444 .ok()
19445 .map(|()| cursor)
19446 });
19447 let Some(next_cursor) = committed else {
19448 self.subs[sub_index].effective = previous_effective;
19449 self.subs[sub_index].cursor = previous_cursor;
19450 dropped = true;
19451 continue;
19452 };
19453 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
19454 }
19455 if let Some(cursor) = applied_cursor {
19456 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
19458 let _ = transport.realtime_send(&ack);
19459 } else if !any_covered || dropped {
19460 self.set_sync_needed(true, true);
19462 }
19463 }
19464
19465 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
19469 if !self.realtime_connected() {
19470 return;
19471 }
19472 let floor = self
19473 .subs
19474 .iter()
19475 .filter(|s| {
19476 s.state == SubState::Active
19477 && s.bootstrap_state.is_none()
19478 && s.synced_once
19479 && s.cursor >= 0
19480 })
19481 .map(|s| s.cursor)
19482 .min();
19483 if let Some(cursor) = floor {
19484 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
19485 let _ = transport.realtime_send(&ack);
19486 }
19487 }
19488}
19489
19490#[cfg(test)]
19491mod previous_version_wiring_tests {
19492 use super::*;
19493 use serde_json::{json, Value};
19494
19495 fn previous_version_schema(version: i32) -> Value {
19496 json!({
19497 "version": version,
19498 "tables": [{
19499 "name": "tasks",
19500 "primaryKey": "id",
19501 "columns": [
19502 { "name": "id", "type": "string", "nullable": false },
19503 { "name": "note", "type": "string", "nullable": true }
19504 ],
19505 "scopes": []
19506 }]
19507 })
19508 }
19509
19510 fn previous_version_limits() -> crate::api::ClientLimits {
19511 crate::api::ClientLimits {
19512 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
19513 enabled: true,
19514 ..Default::default()
19515 }),
19516 ..Default::default()
19517 }
19518 }
19519
19520 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
19522 previous_version_limits()
19523 .previous_version_context
19524 .expect("configured")
19525 }
19526
19527 fn recapture_previous_version(client: &SyncClient) {
19530 let replica_path = client
19531 .previous_version_replica_path()
19532 .expect("file-backed replica");
19533 let outcome = crate::previous_version::capture_previous_version_from_replica(
19534 &client.conn,
19535 &replica_path,
19536 2,
19537 2,
19538 &previous_version_config(),
19539 1,
19540 )
19541 .expect("capture");
19542 assert!(matches!(
19543 outcome,
19544 crate::previous_version::CaptureOutcome::Captured(_)
19545 ));
19546 }
19547
19548 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
19549 Subscription {
19550 id: "wiring-sub".to_owned(),
19551 table: "tasks".to_owned(),
19552 requested: Vec::new(),
19553 params: None,
19554 cursor,
19555 bootstrap_state: bootstrap_state.map(str::to_owned),
19556 state,
19557 reason_code: None,
19558 effective: None,
19559 synced_once: false,
19560 }
19561 }
19562
19563 #[test]
19564 fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
19565 let path =
19566 std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
19567 let path_str = path.to_str().expect("temp path");
19568 let schema = previous_version_schema(3);
19569 let mut older = previous_version_schema(2);
19570 older["tables"][0]["columns"] =
19571 json!([{ "name": "id", "type": "string", "nullable": false }]);
19572 let mut client = SyncClient::open_path(
19573 "downgrade".into(),
19574 &previous_version_schema(1),
19575 previous_version_limits(),
19576 path_str,
19577 )
19578 .expect("fresh");
19579 client
19580 .mutate(vec![Mutation::Upsert {
19581 table: "tasks".into(),
19582 values: Map::from_iter([
19583 ("id".into(), json!("accepted")),
19584 ("note".into(), json!("old")),
19585 ]),
19586 base_version: None,
19587 }])
19588 .expect("v1 write");
19589 client
19590 .recreate_with_schema(&schema)
19591 .expect("upgrade captures context");
19592 let commit = client
19593 .mutate(vec![Mutation::Upsert {
19594 table: "tasks".into(),
19595 values: Map::from_iter([
19596 ("id".into(), json!("queued")),
19597 ("note".into(), json!("v3-only")),
19598 ]),
19599 base_version: None,
19600 }])
19601 .expect("queued v3 write");
19602 let changes = client.conn.total_changes();
19603 let outbox = client.pending_commit_ids();
19604 assert!(outbox.contains(&commit));
19605 let descriptor = client.get_meta("localSchemaDescriptor");
19606 let container = format!(
19607 "{path_str}{}",
19608 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
19609 );
19610 let context = std::fs::read(&container).expect("captured context");
19611 assert!(client
19612 .recreate_with_schema(&older)
19613 .expect_err("refuse recreate")
19614 .starts_with("client.schema_downgrade:"));
19615 assert_eq!(client.schema.version, 3);
19616 assert_eq!(client.conn.total_changes(), changes);
19617 assert_eq!(client.pending_commit_ids(), outbox);
19618 assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
19619 assert_eq!(
19620 client
19621 .conn
19622 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
19623 row.get::<_, String>(0)
19624 })
19625 .expect("queued row"),
19626 "v3-only"
19627 );
19628 assert_eq!(std::fs::read(&container).expect("context remains"), context);
19629 drop(client);
19630 let before = std::fs::read(&path).expect("replica bytes");
19631 for entry in 0..3 {
19634 let error = match entry {
19635 0 => SyncClient::open_path(
19636 "downgrade".into(),
19637 &older,
19638 previous_version_limits(),
19639 path_str,
19640 ),
19641 1 => SyncClient::open_path_with_identity(
19642 None,
19643 &older,
19644 previous_version_limits(),
19645 path_str,
19646 ),
19647 _ => SyncClient::with_connection(
19648 "downgrade".into(),
19649 &older,
19650 previous_version_limits(),
19651 Connection::open(&path).expect("connection"),
19652 ),
19653 }
19654 .err()
19655 .expect("refuse open");
19656 assert!(error.starts_with("client.schema_downgrade:"));
19657 let conn = Connection::open(&path).expect("inspect");
19658 assert_eq!(
19659 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
19660 .expect("journal mode"),
19661 "delete"
19662 );
19663 drop(conn);
19664 assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
19665 assert_eq!(
19666 std::fs::read(&container).expect("context unchanged"),
19667 context
19668 );
19669 }
19670 let reopened = SyncClient::open_path(
19671 "downgrade".into(),
19672 &schema,
19673 previous_version_limits(),
19674 path_str,
19675 )
19676 .expect("compatible reopen");
19677 assert_eq!(reopened.pending_commit_ids(), outbox);
19678 assert_eq!(
19679 reopened
19680 .conn
19681 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
19682 row.get::<_, String>(0)
19683 })
19684 .expect("queued row remains"),
19685 "v3-only"
19686 );
19687 reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
19688 reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
19689 drop(reopened);
19690 let legacy = SyncClient::open_path(
19691 "downgrade".into(),
19692 &schema,
19693 previous_version_limits(),
19694 path_str,
19695 )
19696 .expect("legacy no-marker reopen");
19697 assert_eq!(legacy.pending_commit_ids(), outbox);
19698 assert!(!legacy.upgrading());
19699 assert_eq!(
19700 legacy
19701 .conn
19702 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
19703 row.get::<_, String>(0)
19704 })
19705 .expect("legacy row remains"),
19706 "v3-only"
19707 );
19708 drop(legacy);
19709 std::fs::remove_file(path).expect("remove replica");
19710 let _ = std::fs::remove_file(container);
19711 }
19712
19713 #[test]
19714 fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
19715 for marker in [
19716 "''",
19717 "'bad'",
19718 "'0'",
19719 "'-1'",
19720 "'2.5'",
19721 "'03'",
19722 "'3\n'",
19723 "' 3'",
19724 "'+3'",
19725 "'3e0'",
19726 "'2147483648'",
19727 "NULL",
19728 "X'33'",
19729 ] {
19730 let conn = Connection::open_in_memory().expect("connection");
19731 conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
19732 let error = SyncClient::with_connection(
19733 "marker".into(),
19734 &previous_version_schema(3),
19735 ClientLimits::default(),
19736 conn,
19737 )
19738 .err()
19739 .expect("refuse marker");
19740 assert!(
19741 error.starts_with("sync.local_corrupt:"),
19742 "{marker}: {error}"
19743 );
19744 }
19745 for fault in [
19746 "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
19747 "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
19748 ] {
19749 let mut client = SyncClient::new(
19750 "marker".into(),
19751 &previous_version_schema(3),
19752 ClientLimits::default(),
19753 )
19754 .expect("fresh");
19755 let commit = client
19756 .mutate(vec![Mutation::Upsert {
19757 table: "tasks".into(),
19758 values: Map::from_iter([("id".into(), json!("queued"))]),
19759 base_version: None,
19760 }])
19761 .expect("queued write");
19762 client.conn.execute_batch(fault).expect("marker fault");
19763 let changes = client.conn.total_changes();
19764 let error = client
19765 .recreate_with_schema(&previous_version_schema(4))
19766 .expect_err("refuse corrupt marker");
19767 assert!(error.starts_with("sync.local_corrupt:"));
19768 assert_eq!(client.conn.total_changes(), changes);
19769 assert_eq!(client.schema.version, 3);
19770 assert_eq!(client.pending_commit_ids(), vec![commit]);
19771 }
19772 for setup in [
19773 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
19774 "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
19775 ] {
19776 let path =
19777 std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
19778 let conn = Connection::open(&path).expect("connection");
19779 conn.execute_batch(setup)
19780 .expect("unreadable metadata fixture");
19781 drop(conn);
19782 let before = std::fs::read(&path).expect("before");
19783 let error = SyncClient::open_path_with_identity(
19784 None,
19785 &previous_version_schema(3),
19786 ClientLimits::default(),
19787 path.to_str().expect("path"),
19788 )
19789 .err()
19790 .expect("refuse unreadable marker");
19791 assert!(error.starts_with("sync.local_corrupt:"));
19792 assert_eq!(std::fs::read(&path).expect("after"), before);
19793 std::fs::remove_file(path).expect("remove fixture");
19794 }
19795 }
19796
19797 std::thread_local! {
19798 static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
19801 const { RefCell::new(None) };
19802 }
19803
19804 fn commit_concurrent_upgrade(attempts: i32) -> bool {
19805 CONCURRENT_UPGRADE.with(|slot| {
19806 if let Some(banner) = slot.borrow_mut().take() {
19807 banner
19808 .execute_batch(
19809 "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
19810 )
19811 .expect("commit the concurrent upgrade");
19812 }
19813 });
19814 attempts < 10
19815 }
19816
19817 fn journal_mode(conn: &Connection) -> String {
19818 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
19819 .expect("journal mode")
19820 }
19821
19822 fn temp_replica(label: &str) -> std::path::PathBuf {
19823 std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
19824 }
19825
19826 #[test]
19827 fn file_identity_constructor_requires_the_returned_wal_mode() {
19828 for path in [":memory:", ""] {
19829 let error = SyncClient::open_path_with_identity(
19830 None,
19831 &previous_version_schema(1),
19832 ClientLimits::default(),
19833 path,
19834 )
19835 .err()
19836 .expect("temporary SQLite databases cannot enter WAL mode");
19837 assert_eq!(
19838 error,
19839 "sync.invalid_request: file-backed client requires WAL journal mode"
19840 );
19841 }
19842 let path = temp_replica("confirmed-wal");
19843 let client = SyncClient::open_path_with_identity(
19844 None,
19845 &previous_version_schema(1),
19846 ClientLimits::default(),
19847 path.to_str().unwrap(),
19848 )
19849 .unwrap();
19850 assert_eq!(journal_mode(&client.conn), "wal");
19851 drop(client);
19852 std::fs::remove_file(path).unwrap();
19853 }
19854
19855 #[test]
19856 fn non_positive_generated_schema_versions_are_refused_before_storage() {
19857 for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
19858 let mut schema = previous_version_schema(1);
19859 schema["version"] = version.clone();
19860 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
19861 .err()
19862 .expect("refuse an out-of-range generated version");
19863 assert_eq!(
19864 error,
19865 "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
19866 );
19867 let path = temp_replica("bad-version");
19869 let path_str = path.to_str().expect("path");
19870 for identity in [false, true] {
19871 let error = if identity {
19872 SyncClient::open_path_with_identity(
19873 None,
19874 &schema,
19875 ClientLimits::default(),
19876 path_str,
19877 )
19878 } else {
19879 SyncClient::open_path(
19880 "bad-version".into(),
19881 &schema,
19882 ClientLimits::default(),
19883 path_str,
19884 )
19885 }
19886 .err()
19887 .expect("refuse before storage");
19888 assert!(
19889 error.starts_with("sync.invalid_request:"),
19890 "{version}: {error}"
19891 );
19892 assert!(
19893 !path.exists(),
19894 "{version}: a refused constructor creates no replica file"
19895 );
19896 }
19897 }
19898 let mut schema = previous_version_schema(1);
19900 schema
19901 .as_object_mut()
19902 .expect("schema object")
19903 .remove("version");
19904 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
19905 .err()
19906 .expect("refuse a missing version");
19907 assert!(error.starts_with("sync.invalid_request:"), "{error}");
19908 }
19909
19910 #[test]
19911 fn a_duplicated_schema_marker_is_refused_without_a_reset() {
19912 let path = temp_replica("duplicate-marker");
19913 let path_str = path.to_str().expect("path");
19914 {
19915 let conn = Connection::open(path_str).expect("fixture");
19918 conn.execute_batch(
19919 "CREATE TABLE _syncular_meta(key TEXT, value);
19920 INSERT INTO _syncular_meta VALUES
19921 ('localSchemaVersion', '1'),
19922 ('localSchemaVersion', '2'),
19923 ('clientId', 'duplicate');",
19924 )
19925 .expect("duplicate marker fixture");
19926 }
19927 let error = SyncClient::open_path_with_identity(
19928 None,
19929 &previous_version_schema(3),
19930 ClientLimits::default(),
19931 path_str,
19932 )
19933 .err()
19934 .expect("refuse a duplicated marker");
19935 assert!(error.starts_with("sync.local_corrupt:"), "{error}");
19936 let probe = Connection::open(path_str).expect("probe");
19937 assert_eq!(
19938 probe
19939 .query_row(
19940 "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
19941 [],
19942 |row| row.get::<_, i64>(0),
19943 )
19944 .expect("marker rows"),
19945 2,
19946 "the duplicated rows are left for the operator, not collapsed"
19947 );
19948 assert_eq!(
19949 probe
19950 .query_row(
19951 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
19952 [],
19953 |row| row.get::<_, i64>(0),
19954 )
19955 .expect("synced tables"),
19956 0,
19957 "a duplicated marker never drives the reset that recreates v3 tables"
19958 );
19959 assert_eq!(
19960 journal_mode(&probe),
19961 "delete",
19962 "the refusal leaves the journal mode alone"
19963 );
19964 drop(probe);
19965 std::fs::remove_file(path).expect("remove replica");
19966 }
19967
19968 #[test]
19969 fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
19970 let path = temp_replica("refused-wal");
19971 let path_str = path.to_str().expect("path");
19972 drop(
19975 SyncClient::open_path(
19976 "owner".into(),
19977 &previous_version_schema(2),
19978 ClientLimits::default(),
19979 path_str,
19980 )
19981 .expect("v2 replica"),
19982 );
19983 let before = std::fs::read(&path).expect("before");
19984 let error = SyncClient::open_path_with_identity(
19985 Some("intruder".into()),
19986 &previous_version_schema(2),
19987 ClientLimits::default(),
19988 path_str,
19989 )
19990 .err()
19991 .expect("refuse the identity mismatch");
19992 assert!(error.starts_with("client.identity_mismatch:"), "{error}");
19993 let probe = Connection::open(path_str).expect("probe");
19994 assert_eq!(
19995 journal_mode(&probe),
19996 "delete",
19997 "a refused open must not persist WAL"
19998 );
19999 assert_eq!(std::fs::read(&path).expect("after"), before);
20000 assert_eq!(
20001 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20002 Some("2")
20003 );
20004 drop(probe);
20005 std::fs::remove_file(path).expect("remove replica");
20006 }
20007
20008 #[test]
20009 fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
20010 let path = temp_replica("marker-race");
20011 let path_str = path.to_str().expect("path");
20012 drop(
20013 SyncClient::open_path_with_identity(
20014 Some("stale".into()),
20015 &previous_version_schema(1),
20016 ClientLimits::default(),
20017 path_str,
20018 )
20019 .expect("v1 replica"),
20020 );
20021 let banner = Connection::open(path_str).expect("banner");
20025 banner
20026 .execute_batch(
20027 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
20028 )
20029 .expect("pending upgrade");
20030 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
20031 let conn = Connection::open(path_str).expect("stale connection");
20032 conn.busy_handler(Some(commit_concurrent_upgrade))
20033 .expect("busy handler");
20034 let error = SyncClient::with_connection(
20035 "stale".into(),
20036 &previous_version_schema(1),
20037 ClientLimits::default(),
20038 conn,
20039 )
20040 .err()
20041 .expect("a concurrent upgrade must refuse the stale open");
20042 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
20043 let probe = Connection::open(path_str).expect("probe");
20044 assert_eq!(
20045 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20046 Some("2"),
20047 "the concurrent upgrade survives"
20048 );
20049 drop(probe);
20050 std::fs::remove_file(path).expect("remove replica");
20051 }
20052
20053 #[test]
20054 fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
20055 let path = temp_replica("recreate-race");
20056 let path_str = path.to_str().expect("path");
20057 let mut client = SyncClient::open_path_with_identity(
20058 Some("stale".into()),
20059 &previous_version_schema(1),
20060 ClientLimits::default(),
20061 path_str,
20062 )
20063 .expect("v1 replica");
20064 client
20065 .mutate(vec![Mutation::Upsert {
20066 table: "tasks".into(),
20067 values: Map::from_iter([("id".into(), json!("queued"))]),
20068 base_version: None,
20069 }])
20070 .expect("queued write");
20071 let commit = client.pending_commit_ids();
20072 let banner = Connection::open(path_str).expect("banner");
20073 banner
20074 .execute_batch(
20075 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
20076 )
20077 .expect("pending upgrade");
20078 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
20079 client
20080 .conn
20081 .busy_handler(Some(commit_concurrent_upgrade))
20082 .expect("busy handler");
20083 let error = client
20084 .recreate_with_schema(&previous_version_schema(1))
20085 .expect_err("a concurrent upgrade must refuse the stale recreate");
20086 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
20087 assert_eq!(client.schema.version, 1, "the compiled schema is restored");
20088 assert_eq!(
20089 client.pending_commit_ids(),
20090 commit,
20091 "the outbox is preserved"
20092 );
20093 let probe = Connection::open(path_str).expect("probe");
20094 assert_eq!(
20095 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20096 Some("2")
20097 );
20098 drop(probe);
20099 drop(client);
20100 std::fs::remove_file(path).expect("remove replica");
20101 }
20102
20103 #[test]
20104 fn failed_schema_record_writes_roll_back_recreation() {
20105 for key in [
20106 LOCAL_SCHEMA_VERSION_KEY,
20107 crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
20108 ] {
20109 let mut client = SyncClient::new(
20110 "schema-record".into(),
20111 &previous_version_schema(1),
20112 ClientLimits::default(),
20113 )
20114 .expect("v1");
20115 let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
20116 let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
20117 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");
20118 client
20119 .recreate_with_schema(&previous_version_schema(2))
20120 .expect_err("both record writes must propagate failure");
20121 assert_eq!(client.schema.version, 1);
20122 assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
20123 assert_eq!(
20124 client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
20125 descriptor
20126 );
20127 client
20128 .conn
20129 .execute_batch("DROP TRIGGER refuse_schema_record")
20130 .expect("remove fault");
20131 client
20132 .recreate_with_schema(&previous_version_schema(2))
20133 .expect("retry");
20134 assert_eq!(client.schema.version, 2);
20135 }
20136 }
20137
20138 #[test]
20139 fn a_failed_outer_commit_restores_every_reset_side_effect() {
20140 let path = temp_replica("outer-commit");
20141 let path_str = path.to_str().expect("path");
20142 let conn = Connection::open(path_str).expect("replica connection");
20145 let mut client = SyncClient::with_connection(
20146 "outer-commit".into(),
20147 &previous_version_schema(1),
20148 previous_version_limits(),
20149 conn,
20150 )
20151 .expect("v1 replica");
20152 assert_eq!(journal_mode(&client.conn), "delete");
20153 let commit = client
20154 .mutate(vec![Mutation::Upsert {
20155 table: "tasks".into(),
20156 values: Map::from_iter([
20157 ("id".into(), json!("queued")),
20158 ("note".into(), json!("dropped-by-v2")),
20159 ]),
20160 base_version: None,
20161 }])
20162 .expect("queued write");
20163 client.drain_change_batches();
20164 let mut dropped = previous_version_schema(2);
20166 dropped["tables"][0]["columns"] =
20167 json!([{ "name": "id", "type": "string", "nullable": false }]);
20168 let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
20171 let revision = client.local_revision();
20172 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
20173 let columns: i64 = client
20174 .conn
20175 .query_row(
20176 "SELECT count(*) FROM pragma_table_info('tasks')",
20177 [],
20178 |row| row.get(0),
20179 )
20180 .expect("column count");
20181 let rebuilds = client.overlay_rebuild_count.get();
20182 let prior_subs = client.subs.len();
20183 let prior_changes = client.change_queue.len();
20184 let prior_intents = client.sync_intent_queue.len();
20185 let prior_last_change = client.last_change.clone();
20186 let prior_overlay_dirty = client.overlay_dirty.get();
20187 let prior_sync_needed = client.sync_needed;
20188 let prior_upgrading = client.upgrading;
20189 let prior_stopped = client.stopped;
20190 let prior_schema_floor = client.schema_floor.clone();
20191 let prior_active_round = client.active_round;
20192 let reader = Connection::open(path_str).expect("reader");
20195 reader
20196 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
20197 .expect("reader snapshot");
20198 client
20199 .conn
20200 .busy_timeout(std::time::Duration::ZERO)
20201 .expect("busy timeout");
20202 let error = client
20203 .recreate_with_schema(&dropped)
20204 .expect_err("a blocked COMMIT must refuse the recreation");
20205 assert!(error.starts_with("client.storage_busy:"), "{error}");
20206 assert!(
20207 client.overlay_rebuild_count.get() > rebuilds,
20208 "the reset ran to its overlay rebuild inside the transaction"
20209 );
20210 assert!(client.conn.is_autocommit(), "the transaction is released");
20211 assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
20213 assert_eq!(client.local_revision(), revision);
20214 assert_eq!(
20215 client.query("SELECT * FROM tasks", &[]).expect("rows"),
20216 rows
20217 );
20218 assert_eq!(
20219 client
20220 .conn
20221 .query_row(
20222 "SELECT count(*) FROM pragma_table_info('tasks')",
20223 [],
20224 |row| { row.get::<_, i64>(0) }
20225 )
20226 .expect("column count"),
20227 columns,
20228 "the previous table layout survives the rollback"
20229 );
20230 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
20231 assert_eq!(client.schema.version, 1, "compiled schema");
20234 assert_eq!(client.subs.len(), prior_subs);
20235 assert_eq!(client.change_queue.len(), prior_changes);
20236 assert_eq!(client.sync_intent_queue.len(), prior_intents);
20237 assert_eq!(client.last_change, prior_last_change);
20238 assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
20239 assert_eq!(client.sync_needed, prior_sync_needed);
20240 assert_eq!(client.upgrading, prior_upgrading);
20241 assert_eq!(client.stopped, prior_stopped);
20242 assert_eq!(client.schema_floor, prior_schema_floor);
20243 assert_eq!(client.active_round, prior_active_round);
20244 assert!(
20245 client.rejections.is_empty(),
20246 "the dropped-column rejection is rolled back too"
20247 );
20248 let container = crate::previous_version::previous_version_container_path(path_str);
20251 assert!(std::path::Path::new(&container).exists());
20252 drop(reader);
20255 let snapshot = client
20256 .previous_version_snapshot(&PreviousVersionReadSpec {
20257 table: "tasks".into(),
20258 ..Default::default()
20259 })
20260 .expect("refuse stale capture");
20261 assert!(!snapshot.available);
20262 assert_eq!(snapshot.current_version, 1);
20263 assert!(!std::path::Path::new(&container).exists());
20264 client
20269 .conn
20270 .busy_timeout(std::time::Duration::from_secs(1))
20271 .expect("busy timeout");
20272 client
20273 .recreate_with_schema(&dropped)
20274 .expect("the recreation succeeds once the reader releases");
20275 assert_eq!(client.schema.version, 2);
20276 assert_eq!(
20277 client.pending_commit_ids(),
20278 vec![commit.clone()],
20279 "the reset kept the incompatible commit pending"
20280 );
20281 assert!(
20282 client.rejections.is_empty(),
20283 "no rejection exists before the send-time prepass"
20284 );
20285 client
20287 .prepare_sync_round(false)
20288 .expect("the send prepass runs during round preparation");
20289 assert!(client.pending_commit_ids().is_empty());
20290 assert!(
20291 client
20292 .rejections
20293 .iter()
20294 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
20295 "the dropped-column commit surfaced as a rejection at send time"
20296 );
20297 client.previous_version_discard();
20298 drop(client);
20299 std::fs::remove_file(path).expect("remove replica");
20300 }
20301
20302 #[test]
20303 fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
20304 let v1 = previous_version_schema(1);
20305 let mut v2 = previous_version_schema(2);
20306 v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
20308 let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
20309 let dropped = client
20310 .mutate(vec![Mutation::Upsert {
20311 table: "tasks".into(),
20312 values: Map::from_iter([
20313 ("id".into(), json!("dropped")),
20314 ("note".into(), json!("gone")),
20315 ]),
20316 base_version: None,
20317 }])
20318 .unwrap();
20319 let survivor = client
20323 .mutate(vec![Mutation::Delete {
20324 table: "tasks".into(),
20325 row_id: "gone".into(),
20326 base_version: None,
20327 }])
20328 .unwrap();
20329 client
20330 .recreate_with_schema(&v2)
20331 .expect("the reset preserves the outbox");
20332 assert_eq!(
20333 client.pending_commit_ids(),
20334 vec![dropped.clone(), survivor.clone()],
20335 "the reset preserves both commits"
20336 );
20337 assert!(client.rejections.is_empty(), "the reset classifies nothing");
20338 client.drain_change_batches();
20339 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
20340 assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
20341
20342 for fault in [
20348 "outcome-insert",
20349 "outbox-delete",
20350 "overlay-rebuild",
20351 "publish-revision",
20352 ] {
20353 client.set_sync_needed(false, false);
20354 client.drain_change_batches();
20355 let revision = client.local_revision();
20356 let trigger = match fault {
20357 "outcome-insert" => {
20358 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20359 }
20360 "outbox-delete" => {
20361 "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20362 }
20363 "publish-revision" => {
20368 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20369 }
20370 _ => {
20371 "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20372 }
20373 };
20374 client.conn.execute_batch(trigger).unwrap();
20375 let outcome = client
20376 .prepare_sync_round(false)
20377 .err()
20378 .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
20379 assert!(
20380 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
20381 "{fault}: {outcome:?}"
20382 );
20383 client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
20384 assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
20385 assert_eq!(
20386 client.pending_commit_ids(),
20387 vec![dropped.clone(), survivor.clone()],
20388 "{fault}: the outbox is restored"
20389 );
20390 assert!(
20391 client.rejections.is_empty(),
20392 "{fault}: no in-memory rejection survives"
20393 );
20394 assert!(
20395 client.commit_outcome(&dropped).unwrap().is_none(),
20396 "{fault}: the durable outcome rolled back"
20397 );
20398 assert_eq!(client.local_revision(), revision, "{fault}: revision");
20399 assert_eq!(
20400 client.query("SELECT * FROM tasks", &[]).unwrap(),
20401 rows,
20402 "{fault}: the visible projection is unchanged"
20403 );
20404 assert!(
20405 client.drain_change_batches().is_empty(),
20406 "{fault}: nothing is published"
20407 );
20408 }
20409
20410 client
20413 .conn
20414 .execute_batch(
20415 "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
20416 CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
20417 BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
20418 )
20419 .unwrap();
20420 assert!(client.drop_incompatible_outbox().is_err());
20421 assert!(!client.conn.is_autocommit());
20422 assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
20423 assert_eq!(
20424 client.pending_commit_ids(),
20425 vec![dropped.clone(), survivor.clone()]
20426 );
20427 assert!(client.commit_outcome(&dropped).unwrap().is_none());
20428 client
20429 .conn
20430 .execute_batch("DROP TRIGGER fail_drop; COMMIT")
20431 .unwrap();
20432
20433 client.set_meta(LOG_EPOCH_KEY, "epoch");
20438 client.set_sync_needed(false, false);
20439 client.drain_change_batches();
20440 let revision = client.local_revision();
20441 let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
20442 assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
20443 assert_eq!(
20444 prepared.meta.pushed_ids,
20445 vec![survivor.clone()],
20446 "the incompatible commit is classified before any operation is sent"
20447 );
20448 let rejection = client
20449 .rejections
20450 .iter()
20451 .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
20452 .expect("a sync.outbox_incompatible rejection");
20453 assert_eq!(rejection.client_commit_id, dropped);
20454 assert!(
20455 !rejection.retryable,
20456 "a schema-incompatible commit is final"
20457 );
20458 assert_eq!(
20459 client
20460 .commit_outcome(&dropped)
20461 .unwrap()
20462 .expect("a durable rejected outcome")
20463 .status,
20464 CommitOutcomeStatus::Rejected
20465 );
20466 assert_eq!(client.local_revision(), revision + 1, "one revision");
20467 let batches = client.drain_change_batches();
20468 assert_eq!(batches.len(), 1, "one change batch");
20469 assert!(
20470 batches[0].rejections_changed
20471 && batches[0].outcomes_changed
20472 && batches[0].status.is_some(),
20473 "{:?}",
20474 batches[0]
20475 );
20476 assert!(
20477 batches[0].tables.iter().any(|entry| entry.table == "tasks"),
20478 "{:?}",
20479 batches[0]
20480 );
20481 let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
20482 assert!(
20483 visible.is_empty(),
20484 "the dropped projection is undone: {visible:?}"
20485 );
20486
20487 let path = temp_replica("outbox-drop-commit");
20491 let path_str = path.to_str().expect("path");
20492 let conn = Connection::open(path_str).expect("replica connection");
20493 let mut client =
20494 SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
20495 .expect("v1 replica");
20496 let dropped = client
20497 .mutate(vec![Mutation::Upsert {
20498 table: "tasks".into(),
20499 values: Map::from_iter([
20500 ("id".into(), json!("dropped")),
20501 ("note".into(), json!("gone")),
20502 ]),
20503 base_version: None,
20504 }])
20505 .unwrap();
20506 client
20507 .recreate_with_schema(&v2)
20508 .expect("the reset preserves the outbox");
20509 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
20510 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
20511 assert_eq!(rows.len(), 1, "the optimistic row is visible");
20512 client.set_sync_needed(false, false);
20513 client.drain_change_batches();
20514 let revision = client.local_revision();
20515 let reader = Connection::open(path_str).expect("reader");
20516 reader
20517 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
20518 .expect("reader snapshot");
20519 client
20520 .conn
20521 .busy_timeout(std::time::Duration::ZERO)
20522 .expect("busy timeout");
20523 let outcome = client
20524 .prepare_sync_round(false)
20525 .err()
20526 .expect("a blocked COMMIT must fail the round");
20527 assert!(
20528 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
20529 "{outcome:?}"
20530 );
20531 assert!(client.conn.is_autocommit(), "the savepoint is released");
20532 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
20533 assert!(client.rejections.is_empty());
20534 assert!(client.commit_outcome(&dropped).unwrap().is_none());
20535 assert_eq!(client.local_revision(), revision);
20536 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
20537 assert!(client.drain_change_batches().is_empty());
20538 drop(reader);
20540 client
20541 .conn
20542 .busy_timeout(std::time::Duration::from_secs(1))
20543 .expect("busy timeout");
20544 client
20545 .prepare_sync_round(false)
20546 .expect("the drop succeeds after the reader releases");
20547 assert!(client.pending_commit_ids().is_empty());
20548 assert!(client
20549 .rejections
20550 .iter()
20551 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
20552 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
20553 drop(client);
20554 std::fs::remove_file(path).expect("remove replica");
20555 }
20556
20557 #[test]
20558 fn previous_version_read_reasons_come_from_live_client_state() {
20559 use crate::previous_version::{
20560 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
20561 };
20562 let path = std::env::temp_dir()
20563 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
20564 .to_string_lossy()
20565 .into_owned();
20566 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
20567 {
20568 let mut setup = SyncClient::open_path(
20569 "wiring".to_owned(),
20570 &previous_version_schema(1),
20571 previous_version_limits(),
20572 &path,
20573 )
20574 .expect("v1 install");
20575 setup
20576 .mutate(vec![Mutation::Upsert {
20577 table: "tasks".to_owned(),
20578 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
20579 base_version: None,
20580 }])
20581 .expect("seed a row");
20582 }
20583 let mut client = SyncClient::open_path(
20584 "wiring".to_owned(),
20585 &previous_version_schema(2),
20586 previous_version_limits(),
20587 &path,
20588 )
20589 .expect("v2 bump captures");
20590 assert!(std::path::Path::new(&container).exists());
20591 let spec = PreviousVersionReadSpec {
20592 table: "tasks".to_owned(),
20593 ..Default::default()
20594 };
20595
20596 recapture_previous_version(&client);
20598 client.lease_state = Some(LeaseState {
20599 lease_id: None,
20600 expires_at_ms: None,
20601 error_code: Some("lease.expired".to_owned()),
20602 });
20603 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20604 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
20605 assert!(!snapshot.available);
20606 assert!(!std::path::Path::new(&container).exists());
20607
20608 recapture_previous_version(&client);
20610 client.set_now_ms(1_000);
20611 client.lease_state = Some(LeaseState {
20612 lease_id: Some("lease".to_owned()),
20613 expires_at_ms: Some(1_000),
20614 error_code: None,
20615 });
20616 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20617 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
20618 assert!(!std::path::Path::new(&container).exists());
20619 client.lease_state = None;
20620
20621 recapture_previous_version(&client);
20623 client.subs.push(subscription(SubState::Revoked, 0, None));
20624 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20625 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
20626 assert!(!std::path::Path::new(&container).exists());
20627 client.subs.clear();
20628
20629 recapture_previous_version(&client);
20632 client
20633 .subs
20634 .push(subscription(SubState::Active, 0, Some("resume")));
20635 assert!(!client.previous_version_coverage_complete());
20636 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20637 assert!(snapshot.available);
20638 assert_eq!(snapshot.previous_version, Some(2));
20639 assert_eq!(snapshot.rows.len(), 1);
20640
20641 recapture_previous_version(&client);
20642 client.subs[0].bootstrap_state = None;
20643 assert!(client.previous_version_coverage_complete());
20644 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20645 assert_eq!(
20646 snapshot.reason,
20647 Some(PreviousVersionReason::CoverageComplete)
20648 );
20649 assert!(!std::path::Path::new(&container).exists());
20650 client.subs.clear();
20651
20652 assert!(!client.previous_version_coverage_complete());
20654
20655 drop(client);
20656 let _ = std::fs::remove_file(&path);
20657 }
20658
20659 #[test]
20666 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
20667 let path = std::env::temp_dir()
20668 .join(format!(
20669 "syncular-prev-lifetime-{}.db",
20670 uuid::Uuid::new_v4()
20671 ))
20672 .to_string_lossy()
20673 .into_owned();
20674 let container = format!(
20675 "{path}{}",
20676 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
20677 );
20678 let exists = || std::path::Path::new(&container).exists();
20679 {
20680 let mut setup = SyncClient::open_path(
20681 "lifetime".to_owned(),
20682 &previous_version_schema(1),
20683 previous_version_limits(),
20684 &path,
20685 )
20686 .expect("v1 install");
20687 setup
20688 .mutate(vec![Mutation::Upsert {
20689 table: "tasks".to_owned(),
20690 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
20691 base_version: None,
20692 }])
20693 .expect("seed a row");
20694 }
20695 let mut client = SyncClient::open_path(
20696 "lifetime".to_owned(),
20697 &previous_version_schema(2),
20698 previous_version_limits(),
20699 &path,
20700 )
20701 .expect("v2 bump captures");
20702 client.set_now_ms(1_000);
20705 assert!(exists());
20706
20707 client.lease_state = Some(LeaseState {
20709 lease_id: None,
20710 expires_at_ms: None,
20711 error_code: Some("lease.expired".to_owned()),
20712 });
20713 client.previous_version_lifetime_check();
20714 assert!(!exists());
20715 client.lease_state = None;
20716
20717 recapture_previous_version(&client);
20720 client.subs.push(subscription(SubState::Active, 0, None));
20721 client.previous_version_lifetime_check();
20722 assert!(!exists());
20723 client.subs.clear();
20724
20725 recapture_previous_version(&client);
20727 client.subs.push(subscription(SubState::Revoked, 0, None));
20728 client.previous_version_lifetime_check();
20729 assert!(!exists());
20730 client.subs.clear();
20731
20732 recapture_previous_version(&client);
20735 client
20736 .subs
20737 .push(subscription(SubState::Active, 0, Some("resume")));
20738 client.previous_version_lifetime_check();
20739 assert!(exists());
20740 client.subs.clear();
20741
20742 drop(client);
20743 let _ = std::fs::remove_file(&path);
20744 let _ = std::fs::remove_file(&container);
20745 let _ = std::fs::remove_file(format!("{container}-wal"));
20746 let _ = std::fs::remove_file(format!("{container}-shm"));
20747 }
20748}