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#[cfg(test)]
9089mod subscription_rearm_tests {
9090 use super::*;
9091 use serde_json::json;
9092
9093 fn client() -> SyncClient {
9094 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9095 {"name":"id","type":"string","nullable":false},
9096 {"name":"project_id","type":"string","nullable":false}],
9097 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[],"ftsIndexes":[]}]});
9098 SyncClient::new("rearm".into(), &schema, ClientLimits::default()).expect("client")
9099 }
9100
9101 fn scopes() -> Vec<(String, Vec<String>)> {
9102 vec![("project_id".to_owned(), vec!["p1".to_owned()])]
9103 }
9104
9105 fn store_revoked(client: &mut SyncClient) {
9107 client
9108 .subscribe("sub".into(), "tasks".into(), scopes(), None)
9109 .expect("subscribe");
9110 let sub = &mut client.subs[0];
9111 sub.cursor = 41;
9112 sub.bootstrap_state = Some("resume".to_owned());
9113 sub.effective = Some(scopes());
9114 sub.state = SubState::Revoked;
9115 sub.reason_code = Some("sync.scope_revoked".to_owned());
9116 client.persist_sub(&client.subs[0]).expect("persist");
9117 client.sync_needed = false;
9118 client.sync_intent_queue.clear();
9119 }
9120
9121 #[test]
9122 fn resubscribing_the_same_intent_rearms_a_revoked_registration() {
9123 let mut client = client();
9124 store_revoked(&mut client);
9125
9126 client
9127 .subscribe("sub".into(), "tasks".into(), scopes(), None)
9128 .expect("re-arm");
9129
9130 let view = client.subscription_state("sub").expect("state");
9131 assert_eq!(view.status, "active");
9132 assert_eq!(view.cursor, -1);
9133 assert!(!view.has_resume_token);
9134 assert_eq!(view.effective_scopes, None);
9135 assert_eq!(view.reason_code, None);
9136 assert!(client.sync_needed, "a re-arm asks for a pull");
9137 assert!(matches!(
9138 client.drain_sync_intents().as_slice(),
9139 [SyncIntent::Interactive]
9140 ));
9141 let stored: String = client
9142 .conn
9143 .query_row(
9144 "SELECT state_json FROM _syncular_subscriptions WHERE id = 'sub'",
9145 [],
9146 |row| row.get(0),
9147 )
9148 .expect("persisted row");
9149 let stored: Value = serde_json::from_str(&stored).expect("stored json");
9150 assert_eq!(stored["status"], json!("active"));
9151 assert_eq!(stored["cursor"], json!(-1));
9152 assert_eq!(stored["bootstrapState"], Value::Null);
9153 assert_eq!(stored["effectiveScopes"], Value::Null);
9154 assert_eq!(stored["reasonCode"], Value::Null);
9155 }
9156
9157 #[test]
9158 fn a_failed_registration_is_not_rearmed() {
9159 let mut client = client();
9160 client
9161 .subscribe("sub".into(), "tasks".into(), scopes(), None)
9162 .expect("subscribe");
9163 client.subs[0].state = SubState::Failed;
9164 client.subs[0].reason_code = Some("sync.scope_revoked".to_owned());
9165 client.persist_sub(&client.subs[0]).expect("persist");
9166 client.sync_needed = false;
9167 client.sync_intent_queue.clear();
9168
9169 client
9170 .subscribe("sub".into(), "tasks".into(), scopes(), None)
9171 .expect("subscribe");
9172
9173 let view = client.subscription_state("sub").expect("state");
9174 assert_eq!(view.status, "failed");
9175 assert_eq!(view.reason_code.as_deref(), Some("sync.scope_revoked"));
9176 assert!(!client.sync_needed);
9177 assert!(client.drain_sync_intents().is_empty());
9178 }
9179}
9180
9181#[derive(Debug, Clone)]
9182struct OutboxOp {
9183 upsert: bool,
9184 table: String,
9185 row_id: String,
9186 base_version: Option<i64>,
9187 values: Option<Map<String, Value>>,
9191}
9192
9193impl From<&OutboxOp> for CommitOperation {
9194 fn from(operation: &OutboxOp) -> Self {
9195 Self {
9196 table: operation.table.clone(),
9197 row_id: operation.row_id.clone(),
9198 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
9199 base_version: operation.base_version,
9200 values: operation.values.clone(),
9201 }
9202 }
9203}
9204
9205#[derive(Debug, Clone)]
9206struct OutboxCommit {
9207 client_commit_id: String,
9208 ops: Vec<OutboxOp>,
9209}
9210
9211fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
9215 commit
9216 .ops
9217 .iter()
9218 .filter(|operation| operation.upsert)
9219 .map(|operation| {
9220 (
9221 operation.table.as_str(),
9222 operation
9223 .values
9224 .as_ref()
9225 .map(|values| values.keys().map(String::as_str).collect())
9226 .unwrap_or_default(),
9227 )
9228 })
9229 .collect()
9230}
9231
9232type FailedCommit = (OutboxCommit, Map<String, Value>);
9233
9234#[derive(Debug, Clone)]
9235struct CompiledLocalDataPurgeTarget {
9236 table: String,
9237 selectors: Vec<(String, Vec<String>)>,
9238}
9239
9240struct StoredCommitOutcomeRow {
9241 sequence: i64,
9242 client_commit_id: String,
9243 status: String,
9244 recorded_at_ms: i64,
9245 results_json: String,
9246 operations_json: Option<String>,
9247 resolution: String,
9248 resolved_at_ms: Option<i64>,
9249 replacement_client_commit_id: Option<String>,
9250}
9251
9252impl StoredCommitOutcomeRow {
9253 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
9254 Ok(Self {
9255 sequence: row.get(0)?,
9256 client_commit_id: row.get(1)?,
9257 status: row.get(2)?,
9258 recorded_at_ms: row.get(3)?,
9259 results_json: row.get(4)?,
9260 operations_json: row.get(5)?,
9261 resolution: row.get(6)?,
9262 resolved_at_ms: row.get(7)?,
9263 replacement_client_commit_id: row.get(8)?,
9264 })
9265 }
9266}
9267
9268enum SectionError {
9271 FailClosed,
9272 Abort(String, String),
9273}
9274
9275#[derive(Debug)]
9276pub(crate) struct RequestMeta {
9277 pushed_ids: Vec<String>,
9278 fresh: Vec<(String, bool)>,
9281 pub(crate) accept: u8,
9282 deferred_commits: usize,
9286}
9287
9288#[derive(Default)]
9289struct ChangeAccumulator {
9290 tables: BTreeMap<String, Option<BTreeSet<String>>>,
9292 windows: BTreeMap<(String, String), BTreeSet<String>>,
9293 status: bool,
9294 conflicts: bool,
9295 rejections: bool,
9296 outcomes: bool,
9297}
9298
9299impl ChangeAccumulator {
9300 fn table(&mut self, table: &str) {
9301 self.tables.insert(table.to_owned(), None);
9302 }
9303
9304 fn scope(&mut self, table: &str, key: String) {
9305 match self.tables.get_mut(table) {
9306 Some(None) => {}
9307 Some(Some(keys)) => {
9308 keys.insert(key);
9309 }
9310 None => {
9311 self.tables
9312 .insert(table.to_owned(), Some(BTreeSet::from([key])));
9313 }
9314 }
9315 }
9316
9317 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
9318 self.windows
9319 .entry((base_key.to_owned(), table.to_owned()))
9320 .or_default()
9321 .insert(unit.to_owned());
9322 }
9323
9324 fn touched(&self) -> bool {
9325 !self.tables.is_empty()
9326 || !self.windows.is_empty()
9327 || self.status
9328 || self.conflicts
9329 || self.rejections
9330 || self.outcomes
9331 }
9332}
9333
9334const PUSH_OPS_PER_REQUEST: usize = 500;
9340const MAX_LOCAL_PURGE_TARGETS: usize = 64;
9341const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
9342const MAX_LOCAL_PURGE_VALUES: usize = 128;
9343const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
9344pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
9345
9346struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
9348
9349impl OverlayDirty {
9350 fn clean() -> Self {
9351 Self(RefCell::new(Some(BTreeSet::new())))
9352 }
9353 fn get(&self) -> bool {
9354 self.0
9355 .borrow()
9356 .as_ref()
9357 .is_none_or(|tables| !tables.is_empty())
9358 }
9359 fn set(&self, dirty: bool) {
9360 *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
9361 }
9362 fn table(&self, table: &str) {
9363 if let Some(tables) = self.0.borrow_mut().as_mut() {
9364 tables.insert(table.to_owned());
9365 }
9366 }
9367 fn snapshot(&self) -> Option<BTreeSet<String>> {
9368 self.0.borrow().clone()
9369 }
9370 fn restore(&self, tables: Option<BTreeSet<String>>) {
9371 *self.0.borrow_mut() = tables;
9372 }
9373}
9374
9375pub struct SyncClient {
9376 #[cfg(feature = "bench-internals")]
9377 benchmark_phases: Recorder,
9378 conn: Connection,
9379 schema: ClientSchema,
9380 client_id: String,
9381 limits: ClientLimits,
9382 subs: Vec<Subscription>,
9383 outbox: Vec<OutboxCommit>,
9384 failed_commits: Vec<FailedCommit>,
9385 retain_failed_commits: bool,
9386 conflicts: Vec<ConflictRecord>,
9387 rejections: Vec<RejectionRecord>,
9388 schema_floor: Option<SchemaFloor>,
9389 lease_state: Option<LeaseState>,
9391 stopped: bool,
9393 upgrading: bool,
9395 sync_needed: bool,
9397 realtime_policy: RealtimePolicy,
9400 realtime_state: RealtimeState,
9401 realtime_reason_code: Option<String>,
9402 realtime_retry_delay_ms: Option<u64>,
9403 presence: HashMap<String, HashMap<String, PresencePeer>>,
9405 now_ms: Option<i64>,
9408 encryption: crate::values::EncryptionConfig,
9412 security_preflight: bool,
9415 transport_enabled: bool,
9416 previous_version: Option<PreviousVersionContextConfig>,
9420 insert_sql: RefCell<HashMap<String, String>>,
9426 overlay_dirty: OverlayDirty,
9429 storage_failure: RefCell<Option<QueryReadFailure>>,
9431 #[cfg(test)]
9434 overlay_rebuild_count: Cell<usize>,
9435 #[cfg(test)]
9436 acknowledged_replay_count: Cell<usize>,
9437 #[cfg(test)]
9440 outcome_prune_count: Cell<usize>,
9441 progress: ProgressObserver,
9443 change_queue: VecDeque<ClientChangeBatch>,
9444 sync_intent_queue: VecDeque<SyncIntent>,
9445 retry_delay_ms: u64,
9447 round_retry_delay_ms: Option<u64>,
9449 active_round: Option<uuid::Uuid>,
9450 last_round: Option<DiagnosticLastRound>,
9451 last_change: Option<DiagnosticLastChange>,
9452 query_failures: Vec<DiagnosticQueryFailure>,
9454}
9455
9456pub(crate) fn quote_ident(name: &str) -> String {
9457 format!("\"{}\"", name.replace('"', "\"\""))
9458}
9459pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
9463 conn.query_row(
9464 "SELECT value FROM _syncular_meta WHERE key = ?1",
9465 rusqlite::params![key],
9466 |row| row.get::<_, String>(0),
9467 )
9468 .ok()
9469}
9470
9471pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
9472 let _ = conn.execute(
9473 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
9474 rusqlite::params![key, value],
9475 );
9476}
9477
9478pub(crate) fn meta_delete(conn: &Connection, key: &str) {
9479 let _ = conn.execute(
9480 "DELETE FROM _syncular_meta WHERE key = ?1",
9481 rusqlite::params![key],
9482 );
9483}
9484
9485fn row_id_predicate(table: &TableSchema) -> String {
9487 let key = quote_ident(&table.primary_key);
9488 let text_match = format!("CAST({key} AS TEXT) = ?1");
9489 match table.columns[table.pk_index].ty {
9490 ColumnType::String | ColumnType::Json => {
9491 format!("{key} = ?1 AND {text_match}")
9492 }
9493 ColumnType::Integer | ColumnType::Boolean => {
9497 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
9498 }
9499 _ => text_match,
9502 }
9503}
9504
9505fn is_local_operation_code_like(value: &str) -> bool {
9506 let bytes = value.as_bytes();
9507 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
9508 && bytes
9509 .iter()
9510 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
9511}
9512
9513fn base_table(name: &str) -> String {
9514 quote_ident(&format!("_syncular_base_{name}"))
9515}
9516
9517type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
9522
9523fn window_base_key(base: &WindowBase) -> String {
9524 format!(
9525 "{}\0{}\0{}",
9526 base.table,
9527 base.variable,
9528 canonical_scope_json(&base.fixed_scopes)
9529 )
9530}
9531
9532fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
9534 let mut scopes = base.fixed_scopes.clone();
9535 scopes.retain(|(k, _)| k != &base.variable);
9536 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
9537 scopes
9538}
9539
9540fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
9544 let canonical = canonical_scope_json(&unit_scopes(base, unit));
9545 let digest = Sha256::digest(canonical.as_bytes());
9546 let hex = bytes_to_hex(&digest);
9547 format!("w:{}:{}", base.table, &hex[..16])
9548}
9549
9550fn visible_table(name: &str) -> String {
9551 quote_ident(name)
9552}
9553
9554const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
9555
9556fn is_synced_table_name(name: &str) -> bool {
9559 if name.starts_with("sqlite_") {
9560 return false;
9561 }
9562 if name.starts_with("_syncular_base_") {
9563 return true; }
9565 !name.starts_with("_syncular_")
9567}
9568
9569fn blob_id_for(bytes: &[u8]) -> String {
9571 let digest = Sha256::digest(bytes);
9572 format!("sha256:{}", bytes_to_hex(&digest))
9573}
9574
9575enum RowParam<'a> {
9579 Cell(&'a Option<ColumnValue>),
9580 Version(i64),
9581}
9582
9583impl rusqlite::ToSql for RowParam<'_> {
9584 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
9585 Ok(match self {
9586 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
9587 RowParam::Cell(cell) => match cell {
9588 None => ToSqlOutput::Owned(SqlValue::Null),
9589 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
9590 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
9591 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
9592 Some(ColumnValue::Boolean(b)) => {
9593 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
9594 }
9595 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
9596 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
9597 }
9598 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
9600 ToSqlOutput::Borrowed(ValueRef::Blob(b))
9601 }
9602 },
9603 })
9604 }
9605}
9606
9607fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
9609 match param.to_sql().map_err(|error| error.to_string())? {
9610 ToSqlOutput::Owned(value) => Ok(value),
9611 ToSqlOutput::Borrowed(value) => {
9612 SqlValue::try_from(value).map_err(|error| error.to_string())
9613 }
9614 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
9615 }
9616}
9617
9618fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
9626 use ssp2::segment::ColumnType;
9627 let mismatch = || {
9628 Err(format!(
9629 "image column {:?} holds a value of the wrong type",
9630 column.name
9631 ))
9632 };
9633 match value {
9634 ValueRef::Null => {
9635 if !column.nullable {
9636 return Err(format!(
9637 "image column {:?} is NULL but not nullable",
9638 column.name
9639 ));
9640 }
9641 Ok(ToSqlOutput::Owned(SqlValue::Null))
9642 }
9643 ValueRef::Integer(i) => match column.ty {
9644 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
9645 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
9646 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
9647 _ => mismatch(),
9648 },
9649 ValueRef::Real(_) => match column.ty {
9650 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
9651 _ => mismatch(),
9652 },
9653 ValueRef::Text(t) => {
9654 std::str::from_utf8(t)
9655 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
9656 match column.ty {
9657 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
9658 Ok(ToSqlOutput::Borrowed(value))
9659 }
9660 _ => mismatch(),
9661 }
9662 }
9663 ValueRef::Blob(_) => match column.ty {
9664 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
9666 _ => mismatch(),
9667 },
9668 }
9669}
9670
9671pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
9672 use rusqlite::types::ValueRef;
9673 match value {
9674 ValueRef::Null => Value::Null,
9675 ValueRef::Integer(i) => match column.ty {
9676 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
9677 ssp2::segment::ColumnType::Float => {
9678 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
9679 }
9680 _ => Value::from(i),
9681 },
9682 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
9683 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
9684 ValueRef::Blob(b) => {
9685 let mut map = Map::new();
9686 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
9687 Value::Object(map)
9688 }
9689 }
9690}
9691
9692fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
9696 Ok(match value {
9697 Value::Null => SqlValue::Null,
9698 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
9699 Value::Number(n) => {
9700 if let Some(i) = n.as_i64() {
9701 SqlValue::Integer(i)
9702 } else if let Some(f) = n.as_f64() {
9703 SqlValue::Real(f)
9704 } else {
9705 return Err(format!("query param number {n} is out of range"));
9706 }
9707 }
9708 Value::String(s) => SqlValue::Text(s.clone()),
9709 Value::Object(_) => {
9710 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
9711 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
9712 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
9713 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
9714 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
9715 })?)
9716 } else {
9717 return Err(
9718 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
9719 .to_owned(),
9720 );
9721 }
9722 }
9723 Value::Array(_) => return Err("query array params are not supported".to_owned()),
9724 })
9725}
9726
9727fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
9732 use rusqlite::types::ValueRef;
9733 match value {
9734 ValueRef::Null => Value::Null,
9735 ValueRef::Integer(i) => {
9736 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
9740 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
9741 Value::from(i)
9742 } else {
9743 let mut map = Map::new();
9744 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
9745 Value::Object(map)
9746 }
9747 }
9748 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
9749 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
9750 ValueRef::Blob(b) => {
9751 let mut map = Map::new();
9752 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
9753 Value::Object(map)
9754 }
9755 }
9756}
9757
9758fn query_connection(
9760 conn: &Connection,
9761 sql: &str,
9762 params: &[QueryValue],
9763) -> Result<Vec<QueryRow>, QueryReadFailure> {
9764 crate::query_guard::assert_read_only_query(sql)?;
9765 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
9766 let bound: Vec<SqlValue> = params
9767 .iter()
9768 .map(json_param_to_sql)
9769 .collect::<Result<_, _>>()?;
9770 let mut stmt = conn.prepare(&lowered_sql)?;
9771 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
9772 let bound_refs: Vec<&dyn rusqlite::ToSql> =
9773 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
9774 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
9775 let mut out = Vec::new();
9776 while let Some(row) = sql_rows.next()? {
9777 let mut record = Map::new();
9778 for (i, name) in column_names.iter().enumerate() {
9779 let value = row.get_ref(i)?;
9780 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
9781 }
9782 out.push(record);
9783 }
9784 Ok(out)
9785}
9786
9787fn persisted_window_state(
9788 conn: &Connection,
9789 base: &WindowBase,
9790) -> Result<WindowState, QueryReadFailure> {
9791 let mut stmt = conn.prepare(
9792 "SELECT windows.unit, subscriptions.state_json
9793 FROM _syncular_windows AS windows
9794 JOIN _syncular_subscriptions AS subscriptions
9795 ON subscriptions.id = windows.sub_id
9796 WHERE windows.base = ?1
9797 ORDER BY windows.unit ASC",
9798 )?;
9799 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
9800 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9801 })?;
9802 let mut units = Vec::new();
9803 let mut pending = Vec::new();
9804 for row in rows {
9805 let (unit, raw) = row?;
9806 let state: Value = serde_json::from_str(&raw)
9807 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
9808 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
9809 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
9810 || state
9811 .get("bootstrapState")
9812 .is_some_and(|value| !value.is_null());
9813 if is_pending {
9814 pending.push(unit.clone());
9815 }
9816 units.push(unit);
9817 }
9818 Ok(WindowState { units, pending })
9819}
9820
9821#[derive(Debug, Clone, Copy)]
9823pub struct SnapshotStatement<'a> {
9824 pub sql: &'a str,
9825 pub params: &'a [Value],
9826}
9827
9828#[derive(Debug, Clone, Default)]
9832pub struct SnapshotReadRequest<'a> {
9833 pub statements: Vec<SnapshotStatement<'a>>,
9834 pub coverage: &'a [WindowCoverage],
9835 pub subscriptions: Vec<String>,
9836 pub commit_ids: Vec<String>,
9837}
9838
9839#[cfg(test)]
9840thread_local! {
9841 static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
9845 std::cell::RefCell::new(None);
9846}
9847
9848#[cfg(test)]
9849fn run_snapshot_read_hook() {
9850 SNAPSHOT_READ_HOOK.with(|slot| {
9851 if let Some(hook) = slot.borrow_mut().take() {
9852 hook();
9853 }
9854 });
9855}
9856
9857fn snapshot_read_connection(
9862 conn: &Connection,
9863 request: &SnapshotReadRequest<'_>,
9864) -> Result<SnapshotRead, QueryReadFailure> {
9865 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
9866 let corrupt = |message: &str| QueryReadFailure {
9867 code: Some("sync.local_corrupt"),
9868 sqlite_code: None,
9869 sqlite_message: None,
9870 rollback_failure: None,
9871 message: message.to_owned(),
9872 };
9873 let stored_failure = |error: rusqlite::Error, message: &str| match error {
9874 rusqlite::Error::Utf8Error(..)
9875 | rusqlite::Error::InvalidColumnType(..)
9876 | rusqlite::Error::FromSqlConversionFailure(..)
9877 | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
9878 _ => QueryReadFailure::from(error),
9879 };
9880 let result: Result<SnapshotRead, QueryReadFailure> = (|| {
9881 let revision: Option<String> = conn
9884 .query_row(
9885 "SELECT value FROM _syncular_meta WHERE key = ?1",
9886 rusqlite::params![LOCAL_REVISION_KEY],
9887 |row| row.get(0),
9888 )
9889 .optional()
9890 .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
9891 let revision = match revision {
9892 None => "0".to_owned(),
9893 Some(value)
9894 if value == "0"
9895 || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
9896 && value.bytes().all(|b| b.is_ascii_digit())
9897 && value.parse::<u64>().is_ok()) =>
9898 {
9899 value
9900 }
9901 Some(_) => return Err(corrupt("persisted local revision is invalid")),
9902 };
9903 let mut queries = Vec::with_capacity(request.statements.len());
9904 for statement in &request.statements {
9905 queries.push(query_connection(conn, statement.sql, statement.params)?);
9906 #[cfg(test)]
9907 run_snapshot_read_hook();
9908 }
9909 let mut pending = Vec::new();
9910 let mut missing = Vec::new();
9911 for requested in request.coverage {
9912 let base_key = window_base_key(&requested.base);
9913 let state = persisted_window_state(conn, &requested.base)?;
9914 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
9915 let reference = WindowUnitRef {
9916 base_key: base_key.clone(),
9917 unit: unit.clone(),
9918 };
9919 if !state.units.iter().any(|held| held == &unit) {
9920 missing.push(reference);
9921 } else if state.pending.iter().any(|held| held == &unit) {
9922 pending.push(reference);
9923 }
9924 }
9925 }
9926 let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
9927 for id in &request.subscriptions {
9928 let row: Option<(String, String)> = conn
9929 .query_row(
9930 "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
9931 rusqlite::params![id],
9932 |row| Ok((row.get(0)?, row.get(1)?)),
9933 )
9934 .optional()
9935 .map_err(|error| {
9936 stored_failure(error, "persisted subscription state is invalid")
9937 })?;
9938 let Some((table, raw)) = row else {
9939 subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
9940 continue;
9941 };
9942 let state: Value = serde_json::from_str(&raw)
9945 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
9946 let status = match state.get("status").and_then(Value::as_str) {
9947 Some(value @ ("active" | "revoked" | "failed")) => value,
9948 _ => return Err(corrupt("persisted subscription state is invalid")),
9949 };
9950 let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
9951 return Err(corrupt("persisted subscription state is invalid"));
9952 };
9953 if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
9954 return Err(corrupt("persisted subscription state is invalid"));
9955 }
9956 let effective_scopes = state
9957 .get("effectiveScopes")
9958 .filter(|value| !value.is_null());
9959 if let Some(scopes) = effective_scopes {
9960 json_to_scope_map(scopes)
9961 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
9962 }
9963 let has_resume_token = match state.get("bootstrapState") {
9964 None | Some(Value::Null) => false,
9965 Some(Value::String(_)) => true,
9966 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
9967 };
9968 let reason_code = match state.get("reasonCode") {
9971 None | Some(Value::Null) => None,
9972 Some(Value::String(value)) => Some(value.clone()),
9973 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
9974 };
9975 let active = status == "active";
9976 subscriptions.push(SubscriptionCatchup::Known {
9977 id: id.clone(),
9978 table,
9979 status: status.to_owned(),
9980 cursor,
9981 has_resume_token,
9982 bootstrap_complete: active && cursor >= 0 && !has_resume_token,
9983 known_pending_pages: active && (has_resume_token || cursor < 0),
9984 effective_scopes: effective_scopes.cloned(),
9985 reason_code,
9986 });
9987 }
9988 let mut deliveries = Vec::with_capacity(request.commit_ids.len());
9989 for id in &request.commit_ids {
9990 let pending: bool = conn.query_row(
9991 "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
9992 rusqlite::params![id],
9993 |row| row.get(0),
9994 )?;
9995 if pending {
9996 deliveries.push(CommitDelivery::Pending {
9997 client_commit_id: id.clone(),
9998 });
9999 continue;
10000 }
10001 let row = conn
10002 .query_row(
10003 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
10004 resolution, resolved_at_ms, replacement_client_commit_id
10005 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
10006 rusqlite::params![id],
10007 StoredCommitOutcomeRow::from_row,
10008 )
10009 .optional()
10010 .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
10011 deliveries.push(match row {
10012 Some(row) => CommitDelivery::Known {
10013 client_commit_id: id.clone(),
10014 outcome: SyncClient::outcome_from_row(row)
10015 .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
10016 },
10017 None => CommitDelivery::Unknown {
10018 client_commit_id: id.clone(),
10019 },
10020 });
10021 }
10022 Ok(SnapshotRead {
10023 revision,
10024 queries,
10025 coverage: CoverageSnapshot {
10026 complete: pending.is_empty() && missing.is_empty(),
10027 pending,
10028 missing,
10029 },
10030 subscriptions,
10031 deliveries,
10032 })
10033 })();
10034 match result {
10035 Ok(read) => {
10036 conn.execute_batch("RELEASE syncular_snapshot_read")?;
10037 Ok(read)
10038 }
10039 Err(mut error) => {
10040 if let Err(rollback) = conn
10041 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
10042 {
10043 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
10044 }
10045 Err(error)
10046 }
10047 }
10048}
10049
10050fn snapshot_connection(
10051 conn: &Connection,
10052 sql: &str,
10053 params: &[Value],
10054 coverage: &[WindowCoverage],
10055) -> Result<QuerySnapshot, QueryReadFailure> {
10056 let read = snapshot_read_connection(
10057 conn,
10058 &SnapshotReadRequest {
10059 statements: vec![SnapshotStatement { sql, params }],
10060 coverage,
10061 subscriptions: Vec::new(),
10062 commit_ids: Vec::new(),
10063 },
10064 )?;
10065 Ok(QuerySnapshot {
10066 revision: read.revision,
10067 rows: read
10068 .queries
10069 .into_iter()
10070 .next()
10071 .expect("single-query snapshot returns one result"),
10072 coverage: read.coverage,
10073 })
10074}
10075
10076pub struct FileQuerySnapshotReader {
10081 path: String,
10082 conn: Option<Connection>,
10083}
10084
10085impl FileQuerySnapshotReader {
10086 #[must_use]
10087 pub fn new(path: impl Into<String>) -> Self {
10088 Self {
10089 path: path.into(),
10090 conn: None,
10091 }
10092 }
10093
10094 fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
10095 if self.conn.is_none() {
10096 let conn = Connection::open_with_flags(
10097 &self.path,
10098 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
10099 )
10100 .map_err(QueryReadFailure::from)?;
10101 conn.busy_timeout(std::time::Duration::from_millis(250))
10102 .map_err(QueryReadFailure::from)?;
10103 self.conn = Some(conn);
10104 }
10105 self.conn
10106 .as_ref()
10107 .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
10108 }
10109
10110 pub fn snapshot_read(
10113 &mut self,
10114 request: &SnapshotReadRequest<'_>,
10115 ) -> Result<SnapshotRead, QueryReadFailure> {
10116 snapshot_read_connection(self.connection()?, request)
10117 }
10118
10119 pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
10122 Ok(self
10123 .snapshot_read(&SnapshotReadRequest::default())?
10124 .revision)
10125 }
10126
10127 pub fn subscription_catchup(
10129 &mut self,
10130 id: &str,
10131 ) -> Result<SubscriptionCatchup, QueryReadFailure> {
10132 let read = self.snapshot_read(&SnapshotReadRequest {
10133 subscriptions: vec![id.to_owned()],
10134 ..Default::default()
10135 })?;
10136 Ok(read
10137 .subscriptions
10138 .into_iter()
10139 .next()
10140 .expect("one requested catch-up"))
10141 }
10142
10143 pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
10146 let read = self.snapshot_read(&SnapshotReadRequest {
10147 commit_ids: vec![id.to_owned()],
10148 ..Default::default()
10149 })?;
10150 Ok(read
10151 .deliveries
10152 .into_iter()
10153 .next()
10154 .expect("one requested delivery"))
10155 }
10156
10157 pub fn query_snapshot(
10160 &mut self,
10161 sql: &str,
10162 params: &[Value],
10163 coverage: &[WindowCoverage],
10164 ) -> Result<QuerySnapshot, QueryReadFailure> {
10165 snapshot_connection(self.connection()?, sql, params, coverage)
10166 }
10167}
10168
10169fn validate_authority_reads(
10170 schema: &ClientSchema,
10171 declarations: &[crate::api::AuthorityReadDeclaration],
10172) -> Result<(), String> {
10173 let mut seen = BTreeSet::new();
10174 for read in declarations {
10175 let table = schema
10176 .table(&read.table)
10177 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
10178 let plain = |name: &str| {
10179 table.columns.iter().enumerate().any(|(i, c)| {
10180 c.name == name
10181 && name != "authority_version"
10182 && !name.starts_with("_sync")
10183 && !table.encrypted_columns.iter().any(|e| e.index == i)
10184 && matches!(
10185 c.ty,
10186 ssp2::segment::ColumnType::String
10187 | ssp2::segment::ColumnType::Integer
10188 | ssp2::segment::ColumnType::Float
10189 | ssp2::segment::ColumnType::Boolean
10190 | ssp2::segment::ColumnType::Json
10191 )
10192 })
10193 };
10194 if !seen.insert(&read.table)
10195 || read.columns.is_empty()
10196 || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
10197 || !read.columns.contains(&table.primary_key)
10198 || !read.columns.iter().all(|c| plain(c))
10199 || read.scopes.is_empty()
10200 || !read.scopes.iter().all(|(v, values)| {
10201 table
10202 .scope_column(v)
10203 .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
10204 && !values.is_empty()
10205 && values.iter().all(|v| !v.is_empty() && v != "*")
10206 })
10207 {
10208 return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
10209 }
10210 }
10211 Ok(())
10212}
10213
10214fn schema_marker_read_failure(error: rusqlite::Error) -> String {
10219 let failure = QueryReadFailure::from(error);
10220 if failure.code.is_some() {
10221 failure.to_string()
10222 } else {
10223 "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
10224 }
10225}
10226
10227fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
10231 if limits.outcome_retention_max_entries == Some(0) {
10232 return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
10233 }
10234 if [
10235 limits.max_push_commits_per_request,
10236 limits.max_push_operations_per_request,
10237 limits.max_push_request_bytes,
10238 ]
10239 .into_iter()
10240 .flatten()
10241 .any(|value| value == 0 || value > u32::MAX as usize)
10242 {
10243 return Err(
10244 "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
10245 );
10246 }
10247 Ok(())
10248}
10249
10250fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
10251 let corrupt =
10252 || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
10253 let table_type: Option<String> = conn
10254 .query_row(
10255 "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
10256 [],
10257 |row| row.get(0),
10258 )
10259 .optional()
10260 .map_err(schema_marker_read_failure)?;
10261 let Some(table_type) = table_type else {
10262 return Ok(None);
10263 };
10264 if table_type != "table" {
10265 return Err(corrupt());
10266 }
10267 let mut statement = conn
10271 .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
10272 .map_err(schema_marker_read_failure)?;
10273 let mut rows = statement
10274 .query([LOCAL_SCHEMA_VERSION_KEY])
10275 .map_err(schema_marker_read_failure)?;
10276 let value: Option<String> = rows
10277 .next()
10278 .map_err(schema_marker_read_failure)?
10279 .map(|row| row.get(0))
10280 .transpose()
10281 .map_err(schema_marker_read_failure)?;
10282 if rows.next().map_err(schema_marker_read_failure)?.is_some() {
10283 return Err(corrupt());
10284 }
10285 let Some(value) = value else {
10286 let has_descriptor = conn
10287 .query_row(
10288 "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
10289 [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
10290 |_| Ok(()),
10291 )
10292 .optional()
10293 .map_err(schema_marker_read_failure)?
10294 .is_some();
10295 if has_descriptor {
10296 return Err(corrupt());
10297 }
10298 return Ok(None);
10299 };
10300 if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
10301 || !value.bytes().all(|b| b.is_ascii_digit())
10302 {
10303 return Err(corrupt());
10304 }
10305 let version = value.parse::<i32>().map_err(|_| corrupt())?;
10306 if version > requested {
10307 return Err(
10308 "client.schema_downgrade: persisted local schema is newer than the requested schema"
10309 .to_owned(),
10310 );
10311 }
10312 Ok(Some(version))
10313}
10314
10315impl SyncClient {
10316 pub fn new_with_identity(
10317 client_id: Option<String>,
10318 schema_json: &Value,
10319 limits: ClientLimits,
10320 ) -> Result<Self, String> {
10321 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
10322 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
10323 Self::with_connection(resolved, schema_json, limits, conn)
10324 }
10325
10326 pub fn new(
10327 client_id: String,
10328 schema_json: &Value,
10329 limits: ClientLimits,
10330 ) -> Result<Self, String> {
10331 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
10332 Self::with_connection(client_id, schema_json, limits, conn)
10333 }
10334
10335 pub fn open_path(
10341 client_id: String,
10342 schema_json: &Value,
10343 limits: ClientLimits,
10344 path: &str,
10345 ) -> Result<Self, String> {
10346 validate_client_limits(&limits)?;
10347 parse_schema_json(schema_json)?;
10348 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
10349 Self::with_connection(client_id, schema_json, limits, conn)
10350 }
10351
10352 pub fn open_path_with_identity(
10353 client_id: Option<String>,
10354 schema_json: &Value,
10355 limits: ClientLimits,
10356 path: &str,
10357 ) -> Result<Self, String> {
10358 validate_client_limits(&limits)?;
10359 parse_schema_json(schema_json)?;
10362 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
10363 conn.busy_timeout(std::time::Duration::from_millis(250))
10371 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
10372 let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
10373 let journal_mode: String = client
10374 .conn
10375 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
10376 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10377 if journal_mode != "wal" {
10378 return Err(
10379 "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
10380 );
10381 }
10382 Ok(client)
10383 }
10384
10385 #[must_use]
10386 pub fn client_id(&self) -> &str {
10387 &self.client_id
10388 }
10389
10390 pub fn with_connection(
10397 client_id: String,
10398 schema_json: &Value,
10399 limits: ClientLimits,
10400 conn: Connection,
10401 ) -> Result<Self, String> {
10402 Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
10403 }
10404
10405 fn with_connection_identity(
10408 client_id: Option<String>,
10409 schema_json: &Value,
10410 limits: ClientLimits,
10411 conn: Connection,
10412 ) -> Result<Self, String> {
10413 validate_client_limits(&limits)?;
10414 let schema = parse_schema_json(schema_json)?;
10415 validate_authority_reads(&schema, &limits.authority_reads)?;
10416 let previous_version = match limits.previous_version_context {
10419 Some(config) => {
10420 config.validate()?;
10421 Some(config)
10422 }
10423 None => None,
10424 };
10425 let mut client = SyncClient {
10426 #[cfg(feature = "bench-internals")]
10427 benchmark_phases: Recorder::default(),
10428 conn,
10429 schema,
10430 client_id: client_id
10431 .clone()
10432 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
10433 limits,
10434 subs: Vec::new(),
10435 outbox: Vec::new(),
10436 failed_commits: Vec::new(),
10437 retain_failed_commits: false,
10438 conflicts: Vec::new(),
10439 rejections: Vec::new(),
10440 schema_floor: None,
10441 lease_state: None,
10442 stopped: false,
10443 upgrading: false,
10444 sync_needed: false,
10445 realtime_policy: RealtimePolicy::Optional,
10446 realtime_state: RealtimeState::Disconnected,
10447 realtime_reason_code: None,
10448 realtime_retry_delay_ms: None,
10449 presence: HashMap::new(),
10450 now_ms: None,
10451 encryption: crate::values::EncryptionConfig::default(),
10452 security_preflight: false,
10453 transport_enabled: true,
10454 previous_version,
10455 insert_sql: RefCell::new(HashMap::new()),
10456 overlay_dirty: OverlayDirty::clean(),
10457 storage_failure: RefCell::new(None),
10458 #[cfg(test)]
10459 overlay_rebuild_count: Cell::new(0),
10460 #[cfg(test)]
10461 acknowledged_replay_count: Cell::new(0),
10462 #[cfg(test)]
10463 outcome_prune_count: Cell::new(0),
10464 progress: ProgressObserver::default(),
10465 change_queue: VecDeque::new(),
10466 sync_intent_queue: VecDeque::new(),
10467 retry_delay_ms: 250,
10468 round_retry_delay_ms: None,
10469 active_round: None,
10470 last_round: None,
10471 last_change: None,
10472 query_failures: Vec::new(),
10473 };
10474 client
10478 .conn
10479 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
10480 client
10486 .conn
10487 .execute_batch("BEGIN IMMEDIATE")
10488 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10489 let startup = (|| -> Result<(), String> {
10490 let marker = read_local_schema_version(&client.conn, client.schema.version)?;
10493 client.create_bookkeeping_tables()?;
10496 match client.get_meta(CLIENT_ID_KEY) {
10497 Some(existing) => match client_id {
10498 Some(requested) if existing != requested => {
10499 return Err(format!(
10500 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
10501 ))
10502 }
10503 _ => client.client_id = existing,
10504 },
10505 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
10506 }
10507 client.restore_persisted_state()?;
10508 client.failed_commits = client.load_failed_commits()?;
10509 match marker {
10510 None => {
10511 client.create_synced_tables()?;
10512 client.persist_schema_version()?;
10513 client.save_subscription_scope_schema();
10514 }
10515 Some(version) if version == client.schema.version => {
10516 client.create_synced_tables()?;
10517 set_local_schema_descriptor(&client.conn, &client.schema)
10521 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10522 client.save_subscription_scope_schema();
10523 }
10524 Some(_) => client.run_schema_reset()?,
10525 }
10526 client.reconcile_previous_version_at_boot()?;
10532 client.clear_satisfied_persisted_schema_floor();
10533 client.prune_unknown_subscriptions(false)?;
10534 client.drop_unencodable_outbox(false, None)?;
10543 if marker == Some(client.schema.version)
10544 && (!client.outbox.is_empty()
10545 || !client.failed_commits.is_empty()
10546 || client.has_acknowledged_rows())
10547 {
10548 client.overlay_dirty.set(true);
10552 client.rebuild_overlay()?;
10553 }
10554 client.enqueue_startup_sync_if_needed()?;
10561 Ok(())
10562 })();
10563 match startup {
10564 Ok(()) => match client.conn.execute_batch("COMMIT") {
10565 Ok(()) => Ok(client),
10566 Err(error) => {
10567 let failure = Self::sqlite_failure(&client.storage_failure, error);
10568 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
10569 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
10571 }
10572 Err(failure)
10573 }
10574 },
10575 Err(error) => {
10576 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
10577 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
10579 }
10580 Err(error)
10581 }
10582 }
10583 }
10584
10585 pub fn set_now_ms(&mut self, now_ms: i64) {
10588 self.now_ms = Some(now_ms);
10589 }
10590
10591 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
10595 self.encryption = encryption;
10596 }
10597
10598 #[must_use]
10599 pub fn security_lifecycle(&self) -> &'static str {
10600 if self.security_preflight {
10601 "preflight"
10602 } else {
10603 "active"
10604 }
10605 }
10606
10607 #[must_use]
10608 pub fn security_preflight(&self) -> bool {
10609 self.security_preflight
10610 }
10611
10612 pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
10614 if self.transport_enabled != enabled {
10615 self.transport_enabled = enabled;
10616 self.sync_intent_queue.clear();
10617 self.sync_intent_queue
10618 .push_back(if enabled && !self.security_preflight {
10619 SyncIntent::Interactive
10620 } else {
10621 SyncIntent::None
10622 });
10623 }
10624 if !enabled && self.active_round.is_none() {
10626 self.disconnect_realtime(transport);
10627 }
10628 }
10629
10630 pub fn transport_enabled(&self) -> bool {
10631 self.transport_enabled
10632 }
10633
10634 pub fn begin_security_preflight(&mut self) {
10641 self.seal_security_on_teardown();
10642 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
10645 }
10646
10647 pub fn seal_security_on_teardown(&mut self) {
10657 self.cancel_sync_round();
10658 self.security_preflight = true;
10659 self.encryption = crate::values::EncryptionConfig::default();
10660 self.sync_intent_queue.clear();
10661 }
10662
10663 pub fn activate_security(
10665 &mut self,
10666 encryption: crate::values::EncryptionConfig,
10667 ) -> Result<(), String> {
10668 if !self.security_preflight {
10669 return Err(
10670 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
10671 );
10672 }
10673 self.enqueue_startup_sync_if_needed()?;
10674 self.encryption = encryption;
10675 self.security_preflight = false;
10676 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
10677 Ok(())
10678 }
10679
10680 fn clock_now_ms(&self) -> i64 {
10681 self.now_ms.unwrap_or_else(crate::wall_clock_ms)
10682 }
10683
10684 fn create_bookkeeping_tables(&self) -> Result<(), String> {
10685 self.conn
10687 .execute_batch(
10688 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
10689 seq INTEGER PRIMARY KEY AUTOINCREMENT,
10690 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
10691 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
10692 tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
10693 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
10694 commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
10695 commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
10696 PRIMARY KEY(commit_id, idx));
10697 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
10698 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
10699 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
10700 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
10701 seq INTEGER PRIMARY KEY AUTOINCREMENT,
10702 client_commit_id TEXT NOT NULL UNIQUE,
10703 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
10704 recorded_at_ms INTEGER NOT NULL,
10705 results_json TEXT NOT NULL,
10706 operations_json TEXT,
10707 resolution TEXT NOT NULL DEFAULT 'active'
10708 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
10709 resolved_at_ms INTEGER,
10710 replacement_client_commit_id TEXT);
10711 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
10712 ON _syncular_commit_outcomes(resolution, seq);
10713 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
10714 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
10715 CREATE TABLE IF NOT EXISTS _syncular_meta (
10716 key TEXT PRIMARY KEY, value TEXT NOT NULL);
10717 CREATE TABLE IF NOT EXISTS _syncular_windows (
10718 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
10719 PRIMARY KEY (base, unit));
10720 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
10721 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
10722 effective_scopes TEXT NOT NULL);
10723 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
10724 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
10725 PRIMARY KEY(commit_id, blob_id));
10726 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
10727 ON _syncular_blob_commit_refs(blob_id);",
10728 )
10729 .map_err(|e| e.to_string())?;
10730 let _ = self
10733 .conn
10734 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
10735 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
10736 self.set_meta(LOCAL_REVISION_KEY, "0");
10737 }
10738 if self.schema_has_blobs() {
10741 let exists = self
10742 .conn
10743 .query_row(
10744 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
10745 [],
10746 |row| row.get::<_, bool>(0),
10747 )
10748 .map_err(|e| e.to_string())?;
10749 if exists {
10750 let mut statement = self
10751 .conn
10752 .prepare("PRAGMA table_info(_syncular_blobs)")
10753 .map_err(|e| e.to_string())?;
10754 let mut columns = statement
10755 .query_map([], |row| row.get::<_, String>(1))
10756 .map_err(|e| e.to_string())?
10757 .collect::<Result<Vec<_>, _>>()
10758 .map_err(|e| e.to_string())?;
10759 columns.sort();
10760 let mut expected = vec![
10761 "blob_id",
10762 "byte_length",
10763 "bytes",
10764 "created_at_ms",
10765 "media_type",
10766 ];
10767 expected.sort_unstable();
10768 if columns != expected {
10769 return Err(
10770 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
10771 );
10772 }
10773 } else {
10774 self.conn
10775 .execute_batch(
10776 "CREATE TABLE _syncular_blobs(
10777 blob_id TEXT PRIMARY KEY,
10778 bytes BLOB NOT NULL,
10779 byte_length INTEGER NOT NULL,
10780 media_type TEXT,
10781 created_at_ms INTEGER NOT NULL);",
10782 )
10783 .map_err(|e| e.to_string())?;
10784 }
10785 self.conn
10786 .execute_batch(
10787 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
10788 blob_id TEXT PRIMARY KEY,
10789 media_type TEXT,
10790 created_at_ms INTEGER NOT NULL);",
10791 )
10792 .map_err(|e| e.to_string())?;
10793 }
10794 Ok(())
10795 }
10796
10797 fn schema_has_blobs(&self) -> bool {
10799 self.schema
10800 .tables
10801 .iter()
10802 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
10803 }
10804
10805 fn get_meta(&self, key: &str) -> Option<String> {
10808 meta_get(&self.conn, key)
10809 }
10810
10811 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
10812 self.conn
10813 .query_row(
10814 "SELECT value FROM _syncular_meta WHERE key = ?1",
10815 rusqlite::params![key],
10816 |row| row.get::<_, String>(0),
10817 )
10818 .optional()
10819 .map_err(|_| {
10820 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
10821 })
10822 }
10823
10824 fn persist_schema_version(&self) -> Result<(), String> {
10827 self.conn
10828 .execute(
10829 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
10830 rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
10831 )
10832 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10833 set_local_schema_descriptor(&self.conn, &self.schema)
10834 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
10835 }
10836
10837 fn set_meta(&self, key: &str, value: &str) {
10838 meta_set(&self.conn, key, value);
10839 }
10840
10841 fn delete_meta(&self, key: &str) {
10842 meta_delete(&self.conn, key);
10843 }
10844
10845 fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
10847 let mut stmt = self
10848 .conn
10849 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
10850 .map_err(|error| error.to_string())?;
10851 let rows = stmt
10852 .query_map([], |row| {
10853 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10854 })
10855 .map_err(|error| error.to_string())?;
10856 let mut commits = Vec::new();
10857 for row in rows {
10858 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
10859 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
10860 format!("invalid persisted outbox {client_commit_id:?}: {error}")
10861 })?;
10862 let mut ops = Vec::with_capacity(entries.len());
10863 for entry in entries {
10864 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
10865 ops.push(OutboxOp {
10866 upsert: op == "upsert",
10867 table: entry
10868 .get("table")
10869 .and_then(Value::as_str)
10870 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
10871 .to_owned(),
10872 row_id: entry
10873 .get("rowId")
10874 .and_then(Value::as_str)
10875 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
10876 .to_owned(),
10877 base_version: entry.get("baseVersion").and_then(Value::as_i64),
10878 values: entry.get("values").and_then(Value::as_object).cloned(),
10881 });
10882 }
10883 commits.push(OutboxCommit {
10884 client_commit_id,
10885 ops,
10886 });
10887 }
10888 Ok(commits)
10889 }
10890
10891 fn restore_persisted_state(&mut self) -> Result<(), String> {
10892 self.subs = {
10893 let mut stmt = self
10894 .conn
10895 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
10896 .map_err(|error| error.to_string())?;
10897 let rows = stmt
10898 .query_map([], |row| {
10899 Ok((
10900 row.get::<_, String>(0)?,
10901 row.get::<_, String>(1)?,
10902 row.get::<_, String>(2)?,
10903 ))
10904 })
10905 .map_err(|error| error.to_string())?;
10906 let mut subscriptions = Vec::new();
10907 for row in rows {
10908 let (id, table, raw) = row.map_err(|error| error.to_string())?;
10909 let state: Value = serde_json::from_str(&raw)
10910 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
10911 let requested = json_to_scope_map(
10912 state.get("requested").unwrap_or(&Value::Object(Map::new())),
10913 )?;
10914 let effective = state
10915 .get("effectiveScopes")
10916 .filter(|value| !value.is_null())
10917 .map(json_to_scope_map)
10918 .transpose()?;
10919 subscriptions.push(Subscription {
10920 id,
10921 table,
10922 requested,
10923 params: state
10924 .get("params")
10925 .and_then(Value::as_str)
10926 .map(str::to_owned),
10927 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
10928 bootstrap_state: state
10929 .get("bootstrapState")
10930 .and_then(Value::as_str)
10931 .map(str::to_owned),
10932 state: SubState::parse(
10933 state
10934 .get("status")
10935 .and_then(Value::as_str)
10936 .unwrap_or("active"),
10937 ),
10938 reason_code: state
10939 .get("reasonCode")
10940 .and_then(Value::as_str)
10941 .map(str::to_owned),
10942 effective,
10943 synced_once: state
10944 .get("syncedOnce")
10945 .and_then(Value::as_bool)
10946 .unwrap_or(false),
10947 });
10948 }
10949 subscriptions
10950 };
10951
10952 self.outbox = self.load_outbox()?;
10953
10954 self.prune_commit_outcomes()?;
10955 let active = self.commit_outcomes(CommitOutcomeQuery {
10956 active_only: true,
10957 ..CommitOutcomeQuery::default()
10958 })?;
10959 self.conflicts = active
10960 .iter()
10961 .flat_map(|outcome| outcome.results.iter())
10962 .filter_map(|result| match result {
10963 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
10964 _ => None,
10965 })
10966 .collect();
10967 self.rejections = active
10968 .iter()
10969 .flat_map(|outcome| outcome.results.iter())
10970 .filter_map(|result| match result {
10971 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
10972 _ => None,
10973 })
10974 .collect();
10975
10976 self.lease_state = self
10977 .get_meta(LEASE_STATE_KEY)
10978 .map(|raw| serde_json::from_str(&raw))
10979 .transpose()
10980 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
10981 self.schema_floor = self
10982 .get_meta(SCHEMA_FLOOR_KEY)
10983 .map(|raw| serde_json::from_str(&raw))
10984 .transpose()
10985 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
10986 self.stopped = self.schema_floor.is_some();
10987 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
10990 self.security_preflight = true;
10991 }
10992 Ok(())
10993 }
10994
10995 fn save_subscription_scope_schema(&self) {
10996 let declarations: BTreeMap<_, BTreeMap<_, _>> = self
10997 .schema
10998 .tables
10999 .iter()
11000 .map(|table| {
11001 (
11002 table.name.clone(),
11003 table
11004 .scope_variables
11005 .iter()
11006 .map(|scope| {
11007 (
11008 scope.variable.clone(),
11009 [scope.prefix.clone(), scope.column.clone()],
11010 )
11011 })
11012 .collect(),
11013 )
11014 })
11015 .collect();
11016 self.set_meta(
11017 "subscriptionScopeSchema",
11018 &serde_json::to_string(&declarations).expect("scope schema serialization"),
11019 );
11020 }
11021
11022 fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
11024 let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
11025 self.get_meta("subscriptionScopeSchema")
11026 .map(|raw| serde_json::from_str(&raw))
11027 .transpose()
11028 .map_err(|error| format!("invalid persisted scope schema: {error}"))?
11029 } else {
11030 None
11031 };
11032 let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
11033 let Some(table) = self.schema.table(table_name) else {
11034 return false;
11035 };
11036 if requested
11037 .iter()
11038 .any(|(variable, _)| table.scope_column(variable).is_none())
11039 {
11040 return false;
11041 }
11042 if !bump {
11043 return true;
11044 }
11045 let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
11046 return false;
11047 };
11048 let variables: BTreeSet<_> = if requested.is_empty() {
11049 old.keys()
11050 .cloned()
11051 .chain(
11052 table
11053 .scope_variables
11054 .iter()
11055 .map(|scope| scope.variable.clone()),
11056 )
11057 .collect()
11058 } else {
11059 requested
11060 .iter()
11061 .map(|(variable, _)| variable.clone())
11062 .collect()
11063 };
11064 variables.iter().all(|variable| {
11065 let current = table
11066 .scope_variables
11067 .iter()
11068 .find(|scope| &scope.variable == variable);
11069 match (old.get(variable), current) {
11070 (Some(old), Some(current)) => {
11071 old == &[current.prefix.clone(), current.column.clone()]
11072 }
11073 _ => false,
11074 }
11075 })
11076 };
11077 let mut stale_ids: BTreeSet<_> = self
11078 .subs
11079 .iter()
11080 .filter(|sub| !compatible(&sub.table, &sub.requested))
11081 .map(|sub| sub.id.clone())
11082 .collect();
11083 for (id, table, effective) in self.load_pending_evictions()? {
11084 if !compatible(&table, &effective) {
11085 stale_ids.insert(id);
11086 }
11087 }
11088 for id in &stale_ids {
11089 for sql in [
11090 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
11091 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
11092 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
11093 ] {
11094 self.conn
11095 .execute(sql, rusqlite::params![id])
11096 .map_err(|error| error.to_string())?;
11097 }
11098 }
11099 self.subs.retain(|sub| !stale_ids.contains(&sub.id));
11100 Ok(())
11101 }
11102
11103 #[must_use]
11104 pub fn local_revision(&self) -> u64 {
11105 self.get_meta(LOCAL_REVISION_KEY)
11106 .and_then(|value| value.parse().ok())
11107 .unwrap_or(0)
11108 }
11109
11110 pub fn snapshot_read(
11116 &mut self,
11117 request: &SnapshotReadRequest<'_>,
11118 owner: Option<QueryOwner<'_>>,
11119 ) -> Result<SnapshotRead, QueryReadFailure> {
11120 let result = snapshot_read_connection(&self.conn, request);
11121 if let Some(owner) = owner {
11122 self.record_query_read(owner, result.as_ref().err());
11123 }
11124 result
11125 }
11126
11127 #[must_use]
11128 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
11129 SyncStatusSnapshot {
11130 current_schema_version: self.schema.version,
11131 outbox: self.outbox.len(),
11132 upgrading: self.upgrading,
11133 lease_state: self.lease_state.clone(),
11134 schema_floor: self.schema_floor.clone(),
11135 sync_needed: self.sync_needed,
11136 previous_version_context: self.previous_version_status(),
11137 }
11138 }
11139
11140 fn previous_version_status(&self) -> PreviousVersionStatus {
11143 let Some(replica_path) = self.previous_version_replica_path() else {
11144 return PreviousVersionStatus {
11145 present: false,
11146 created_at_ms: None,
11147 };
11148 };
11149 match crate::previous_version::read_previous_version_record(&replica_path) {
11150 Ok(Some(record)) => PreviousVersionStatus {
11151 present: true,
11152 created_at_ms: Some(record.created_at_ms),
11153 },
11154 _ => PreviousVersionStatus {
11155 present: std::path::Path::new(
11156 &crate::previous_version::previous_version_container_path(&replica_path),
11157 )
11158 .exists(),
11159 created_at_ms: None,
11160 },
11161 }
11162 }
11163
11164 pub fn previous_version_snapshot(
11170 &self,
11171 spec: &PreviousVersionReadSpec,
11172 ) -> Result<PreviousVersionSnapshot, String> {
11173 let now_ms = self.clock_now_ms();
11174 crate::previous_version::previous_version_snapshot(
11175 &self.conn,
11176 self.previous_version_replica_path().as_deref(),
11177 self.previous_version.as_ref(),
11178 PreviousVersionLifecycle {
11179 lease_inactive: self.previous_version_lease_inactive(now_ms),
11180 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
11181 coverage_complete: self.previous_version_coverage_complete(),
11182 },
11183 now_ms,
11184 self.schema.version,
11185 spec,
11186 )
11187 }
11188
11189 #[must_use]
11191 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
11192 crate::previous_version::stored_previous_version_audit(&self.conn)
11193 }
11194
11195 pub fn previous_version_discard(
11199 &self,
11200 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
11201 self.drop_previous_version()
11202 }
11203
11204 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
11208 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
11209 present: false,
11210 discarded: false,
11211 };
11212 let Some(replica_path) = self.previous_version_replica_path() else {
11213 return absent;
11214 };
11215 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
11216 .unwrap_or(absent)
11217 }
11218
11219 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
11221 let Some(lease) = &self.lease_state else {
11222 return false;
11223 };
11224 if lease.error_code.is_some() {
11225 return true;
11226 }
11227 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
11228 }
11229
11230 fn previous_version_coverage_complete(&self) -> bool {
11234 let active = self
11235 .subs
11236 .iter()
11237 .filter(|sub| sub.state == SubState::Active)
11238 .collect::<Vec<_>>();
11239 if active.is_empty() {
11240 return false;
11241 }
11242 active
11243 .iter()
11244 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
11245 }
11246
11247 fn previous_version_lifetime_check(&self) {
11251 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
11252 return;
11253 };
11254 let now_ms = self.clock_now_ms();
11255 if self.previous_version_lease_inactive(now_ms) {
11256 let _ = self.drop_previous_version();
11257 return;
11258 }
11259 let Some(replica_path) = self.previous_version_replica_path() else {
11260 return;
11261 };
11262 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
11263 else {
11264 return;
11265 };
11266 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
11267 let _ = self.drop_previous_version();
11268 return;
11269 }
11270 if now_ms - record.created_at_ms > config.max_age_ms {
11271 let _ = self.drop_previous_version();
11272 return;
11273 }
11274 if self.previous_version_coverage_complete() {
11275 let _ = self.drop_previous_version();
11276 }
11277 }
11278
11279 pub fn diagnostics_snapshot(
11280 &self,
11281 request: &ClientDiagnosticsRequest,
11282 ) -> Result<ClientDiagnosticsSnapshot, String> {
11283 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
11284 return Err(format!(
11285 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
11286 ));
11287 }
11288 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
11289 for sub in &self.subs {
11290 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
11291 let complete =
11292 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
11293 let state = match sub.state {
11294 SubState::Revoked => "revoked",
11295 SubState::Failed => "failed",
11296 SubState::Active if reset => "reset",
11297 SubState::Active if complete => "complete",
11298 SubState::Active => "bootstrapping",
11299 };
11300 subscriptions.insert(
11301 sub.id.clone(),
11302 DiagnosticSubscription {
11303 id: sub.id.clone(),
11304 table: sub.table.clone(),
11305 state: state.to_owned(),
11306 complete,
11307 cursor: Some(sub.cursor),
11308 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
11309 },
11310 );
11311 }
11312 for expected in &request.expected_subscriptions {
11313 if expected.id.is_empty() || expected.table.is_empty() {
11314 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
11315 }
11316 if subscriptions
11317 .get(&expected.id)
11318 .is_some_and(|registered| registered.table != expected.table)
11319 {
11320 subscriptions.insert(
11321 expected.id.clone(),
11322 DiagnosticSubscription {
11323 id: expected.id.clone(),
11324 table: expected.table.clone(),
11325 state: "failed".to_owned(),
11326 complete: false,
11327 cursor: None,
11328 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
11329 },
11330 );
11331 } else {
11332 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
11333 DiagnosticSubscription {
11334 id: expected.id.clone(),
11335 table: expected.table.clone(),
11336 state: "unregistered".to_owned(),
11337 complete: false,
11338 cursor: None,
11339 reason_code: None,
11340 }
11341 });
11342 }
11343 }
11344 let mut ordered_subscriptions = Vec::new();
11345 let mut included = BTreeSet::new();
11346 for expected in &request.expected_subscriptions {
11347 if included.insert(expected.id.clone()) {
11348 if let Some(subscription) = subscriptions.get(&expected.id) {
11349 ordered_subscriptions.push(subscription.clone());
11350 }
11351 }
11352 }
11353 for (id, subscription) in &subscriptions {
11354 if included.insert(id.clone()) {
11355 ordered_subscriptions.push(subscription.clone());
11356 }
11357 }
11358 let subscriptions_truncated =
11359 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
11360 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
11361 let captured_at_ms = self.clock_now_ms();
11362 let lease = if let Some(error_code) = self
11363 .lease_state
11364 .as_ref()
11365 .and_then(|state| state.error_code.clone())
11366 {
11367 ClientDiagnosticsLease {
11368 state: "stopped".to_owned(),
11369 expires_at_ms: self
11370 .lease_state
11371 .as_ref()
11372 .and_then(|state| state.expires_at_ms),
11373 error_code: Some(Self::diagnostic_code(&error_code)),
11374 }
11375 } else if let Some(expires_at_ms) = self
11376 .lease_state
11377 .as_ref()
11378 .and_then(|state| state.expires_at_ms)
11379 {
11380 ClientDiagnosticsLease {
11381 state: if expires_at_ms <= captured_at_ms {
11382 "expired".to_owned()
11383 } else {
11384 "active".to_owned()
11385 },
11386 expires_at_ms: Some(expires_at_ms),
11387 error_code: None,
11388 }
11389 } else {
11390 ClientDiagnosticsLease {
11391 state: "none".to_owned(),
11392 expires_at_ms: None,
11393 error_code: None,
11394 }
11395 };
11396 let connectivity = match self.last_round.as_ref() {
11397 _ if !self.transport_enabled && self.active_round.is_none() => "offline",
11398 Some(round) if round.status == "succeeded" => "online",
11399 Some(round)
11400 if round.status == "failed"
11401 && round
11402 .error_code
11403 .as_deref()
11404 .is_some_and(Self::retryable_transport_code) =>
11405 {
11406 "offline"
11407 }
11408 _ => "unknown",
11409 };
11410 Ok(ClientDiagnosticsSnapshot {
11411 version: CLIENT_DIAGNOSTICS_VERSION,
11412 captured_at_ms,
11413 host: ClientDiagnosticsHost {
11414 kind: "direct".to_owned(),
11415 role: "single".to_owned(),
11416 connectivity: connectivity.to_owned(),
11417 realtime: self.realtime_state,
11418 realtime_policy: self.realtime_policy,
11419 realtime_reason_code: self.realtime_reason_code.clone(),
11420 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
11421 },
11422 security_lifecycle: self.security_lifecycle().to_owned(),
11423 schema: ClientDiagnosticsSchema {
11424 current_version: self.schema.version,
11425 upgrading: self.upgrading,
11426 required_version: self
11427 .schema_floor
11428 .as_ref()
11429 .and_then(|floor| floor.required_schema_version),
11430 latest_version: self
11431 .schema_floor
11432 .as_ref()
11433 .and_then(|floor| floor.latest_schema_version),
11434 },
11435 replica: ClientDiagnosticsReplica {
11436 local_revision: self.local_revision().to_string(),
11437 sync_needed: self.sync_needed,
11438 pending_outbox: self.outbox.len(),
11439 },
11440 lease,
11441 subscriptions: ordered_subscriptions,
11442 subscriptions_truncated,
11443 last_round: self.last_round.clone(),
11444 last_change: self.last_change.clone(),
11445 storage: self.diagnostics_storage(),
11446 query_failures: self.query_failures.clone(),
11447 })
11448 }
11449
11450 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
11451 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
11452 let page_count: i64 = self
11453 .conn
11454 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
11455 let page_size: i64 = self
11456 .conn
11457 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
11458 let outbox_bytes: i64 = self
11460 .conn
11461 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
11462 .query_row([], |row| row.get(0))?;
11463 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
11464 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
11465 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
11466 let blob_bytes = if self.schema_has_blobs() {
11467 self.conn
11468 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
11469 .query_row([], |row| row.get(0))?
11470 } else {
11471 0
11472 };
11473 let pressure = self
11474 .limits
11475 .blob_cache_max_bytes
11476 .is_some_and(|limit| blob_bytes > limit);
11477 Ok(ClientDiagnosticsStorage {
11478 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
11479 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
11480 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
11481 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
11482 retained_outcome_entries: Some(outcome_entries.max(0)),
11483 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
11484 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
11485 })
11486 };
11487 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
11488 status: "unreadable".to_owned(),
11489 database_bytes_approx: None,
11490 pending_outbox_bytes_approx: None,
11491 retained_outcome_bytes_approx: None,
11492 retained_outcome_entries: None,
11493 blob_cache_bytes_approx: None,
11494 pressure_reason_code: None,
11495 })
11496 }
11497
11498 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
11499 self.change_queue.drain(..).collect()
11500 }
11501
11502 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
11503 let intents = self.sync_intent_queue.drain(..).collect();
11504 if self.transport_enabled {
11505 intents
11506 } else {
11507 vec![SyncIntent::None]
11508 }
11509 }
11510
11511 fn schedule_background_retry(&mut self) -> u64 {
11512 let delay_ms = self.retry_delay_ms;
11513 self.sync_intent_queue
11514 .push_back(SyncIntent::Background { delay_ms });
11515 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
11516 self.round_retry_delay_ms = Some(delay_ms);
11517 delay_ms
11518 }
11519
11520 fn reset_background_retry(&mut self) {
11521 self.retry_delay_ms = 250;
11522 }
11523
11524 fn retryable_transport_code(code: &str) -> bool {
11525 code == "transport.failed"
11526 || code == "transport.unavailable"
11527 || code == "sync.transport_failed"
11528 }
11529
11530 fn retryable_failure_code(code: &str) -> bool {
11535 Self::retryable_transport_code(code)
11536 || matches!(
11537 code,
11538 "sync.auth_required"
11539 | "sync.auth_lease_required"
11540 | "sync.auth_lease_revoked"
11541 | "sync.internal_error"
11542 | "sync.idempotency_cache_miss"
11543 | "sync.schema_not_ready"
11544 | "sync.segment_expired"
11545 | "sync.rate_limited"
11546 | "sync.websocket_connection_limit"
11547 )
11548 }
11549
11550 fn diagnostic_code(code: &str) -> String {
11551 let valid = !code.is_empty()
11552 && code.len() <= 96
11553 && code.contains('.')
11554 && code
11555 .bytes()
11556 .next()
11557 .is_some_and(|byte| byte.is_ascii_lowercase())
11558 && code.bytes().all(|byte| {
11559 byte.is_ascii_lowercase()
11560 || byte.is_ascii_digit()
11561 || matches!(byte, b'.' | b'_' | b'-')
11562 });
11563 if valid {
11564 code.to_owned()
11565 } else {
11566 "client.unknown_failure".to_owned()
11567 }
11568 }
11569
11570 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
11571 if self.sync_needed != value {
11572 if self.begin_observation("syncular_status").is_ok() {
11573 self.sync_needed = value;
11574 let batch = ChangeAccumulator {
11575 status: true,
11576 ..ChangeAccumulator::default()
11577 };
11578 if self.finish_observation("syncular_status", batch).is_err() {
11579 self.rollback_observation("syncular_status");
11580 }
11581 } else {
11582 self.sync_needed = value;
11583 }
11584 }
11585 if value && interactive {
11586 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11587 }
11588 }
11589
11590 fn sqlite_failure(
11591 storage_failure: &RefCell<Option<QueryReadFailure>>,
11592 error: rusqlite::Error,
11593 ) -> String {
11594 let message = error.to_string();
11595 let failure = QueryReadFailure::from(error);
11596 let code = failure.code;
11597 if code.is_some() && storage_failure.borrow().is_none() {
11598 *storage_failure.borrow_mut() = Some(failure);
11599 }
11600 match code {
11601 Some(code) => format!("{code}: {message}"),
11602 None => message,
11603 }
11604 }
11605
11606 fn begin_observation(&self, name: &str) -> Result<(), String> {
11607 self.conn
11608 .execute_batch(&format!("SAVEPOINT {name}"))
11609 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11610 }
11611
11612 fn rollback_observation(&self, name: &str) {
11613 if let Err(error) = self
11614 .conn
11615 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
11616 {
11617 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
11618 if failure.rollback_failure.is_none() {
11619 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
11620 }
11621 }
11622 }
11623 }
11624
11625 fn finish_observation(
11626 &mut self,
11627 name: &str,
11628 mut batch: ChangeAccumulator,
11629 ) -> Result<(), String> {
11630 #[cfg(feature = "bench-internals")]
11631 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
11632 if self.failed_commits.iter().any(|(commit, _)| {
11633 commit
11634 .ops
11635 .iter()
11636 .any(|operation| batch.tables.contains_key(&operation.table))
11637 }) {
11638 batch.outcomes = true;
11639 }
11640 if !batch.touched() {
11641 self.conn
11642 .execute_batch(&format!("RELEASE {name}"))
11643 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11644 return Ok(());
11645 }
11646 let revision = self
11647 .local_revision()
11648 .checked_add(1)
11649 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
11650 self.conn
11651 .execute(
11652 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11653 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
11654 )
11655 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11656 let status = batch.status.then(|| self.status_snapshot());
11657 let event = ClientChangeBatch {
11658 revision: revision.to_string(),
11659 tables: batch
11660 .tables
11661 .into_iter()
11662 .map(|(table, scope_keys)| TableChange {
11663 table,
11664 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
11665 })
11666 .collect(),
11667 windows: batch
11668 .windows
11669 .into_iter()
11670 .map(|((base_key, table), units)| WindowChange {
11671 base_key,
11672 table,
11673 units: units.into_iter().collect(),
11674 })
11675 .collect(),
11676 status,
11677 conflicts_changed: batch.conflicts,
11678 rejections_changed: batch.rejections,
11679 outcomes_changed: batch.outcomes,
11680 };
11681 self.conn
11682 .execute_batch(&format!("RELEASE {name}"))
11683 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11684 let mut diagnostic_tables = event
11685 .tables
11686 .iter()
11687 .map(|entry| entry.table.clone())
11688 .collect::<BTreeSet<_>>()
11689 .into_iter()
11690 .collect::<Vec<_>>();
11691 let mut diagnostic_windows = event
11692 .windows
11693 .iter()
11694 .map(|entry| entry.table.clone())
11695 .collect::<BTreeSet<_>>()
11696 .into_iter()
11697 .collect::<Vec<_>>();
11698 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
11699 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
11700 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
11701 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
11702 self.last_change = Some(DiagnosticLastChange {
11703 revision: event.revision.clone(),
11704 recorded_at_ms: self.clock_now_ms(),
11705 tables: diagnostic_tables,
11706 windows: diagnostic_windows,
11707 domains_truncated,
11708 status_changed: event.status.is_some(),
11709 conflicts_changed: event.conflicts_changed,
11710 rejections_changed: event.rejections_changed,
11711 outcomes_changed: event.outcomes_changed,
11712 });
11713 self.change_queue.push_back(event);
11714 Ok(())
11715 }
11716
11717 fn record_scope_map(
11718 &self,
11719 batch: &mut ChangeAccumulator,
11720 table_name: &str,
11721 scopes: &[(String, Vec<String>)],
11722 ) {
11723 let Some(table) = self.schema.table(table_name) else {
11724 return;
11725 };
11726 for (variable, values) in scopes {
11727 let Some(scope) = table
11728 .scope_variables
11729 .iter()
11730 .find(|scope| &scope.variable == variable)
11731 else {
11732 continue;
11733 };
11734 for value in values {
11735 batch.scope(table_name, format!("{}:{value}", scope.prefix));
11736 }
11737 }
11738 }
11739
11740 fn record_row_scopes(
11742 &self,
11743 batch: &mut ChangeAccumulator,
11744 table_name: &str,
11745 row_id: &str,
11746 base: bool,
11747 ) -> bool {
11748 let Some(table) = self.schema.table(table_name) else {
11749 return false;
11750 };
11751 if table.scope_variables.is_empty() {
11752 return false;
11753 }
11754 let columns = table
11755 .scope_variables
11756 .iter()
11757 .map(|scope| quote_ident(&scope.column))
11758 .collect::<Vec<_>>()
11759 .join(", ");
11760 let full_table = if base {
11761 base_table(table_name)
11762 } else {
11763 visible_table(table_name)
11764 };
11765 let sql = format!(
11766 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
11767 row_id_predicate(table)
11768 );
11769 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
11770 return false;
11771 };
11772 let values = stmt.query_row(rusqlite::params![row_id], |row| {
11773 let mut values = Vec::with_capacity(table.scope_variables.len());
11774 for index in 0..table.scope_variables.len() {
11775 values.push(row.get::<_, Option<String>>(index)?);
11776 }
11777 Ok(values)
11778 });
11779 let Ok(values) = values else {
11780 return false;
11781 };
11782 let mut recorded = false;
11783 for (scope, value) in table.scope_variables.iter().zip(values) {
11784 if let Some(value) = value {
11785 batch.scope(table_name, format!("{}:{value}", scope.prefix));
11786 recorded = true;
11787 }
11788 }
11789 recorded
11790 }
11791
11792 fn record_commit_changes(
11793 &self,
11794 batch: &mut ChangeAccumulator,
11795 tables: &[String],
11796 changes: &[ssp2::model::Change],
11797 ) {
11798 #[cfg(feature = "bench-internals")]
11799 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
11800 for change in changes {
11801 let Some(table_name) = tables.get(change.table_index as usize) else {
11802 continue;
11803 };
11804 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
11805 if let Some(table) = self.schema.table(table_name) {
11806 for (variable, value) in &change.scopes {
11807 if let Some(scope) = table
11808 .scope_variables
11809 .iter()
11810 .find(|scope| &scope.variable == variable)
11811 {
11812 batch.scope(table_name, format!("{}:{value}", scope.prefix));
11813 precise = true;
11814 }
11815 }
11816 }
11817 if !precise {
11818 batch.table(table_name);
11819 }
11820 }
11821 }
11822
11823 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
11824 if effective.is_empty() {
11825 return false;
11826 }
11827 let Some(table) = self.schema.table(table_name) else {
11828 return false;
11829 };
11830 let mut clauses = Vec::new();
11831 let mut params = Vec::new();
11832 for (variable, values) in effective {
11833 let Some(column) = table.scope_column(variable) else {
11834 return false;
11835 };
11836 if values.is_empty() {
11837 return false;
11838 }
11839 let placeholders = values
11840 .iter()
11841 .map(|value| {
11842 params.push(SqlValue::Text(value.clone()));
11843 "?"
11844 })
11845 .collect::<Vec<_>>()
11846 .join(", ");
11847 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
11848 }
11849 let sql = format!(
11850 "SELECT 1 FROM {} WHERE {} LIMIT 1",
11851 base_table(table_name),
11852 clauses.join(" AND ")
11853 );
11854 self.conn
11855 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
11856 .is_ok()
11857 }
11858
11859 pub fn upgrading(&self) -> bool {
11861 self.upgrading
11862 }
11863
11864 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
11872 let new_schema = parse_schema_json(schema_json)?;
11873 validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
11874 self.conn
11875 .execute_batch("BEGIN IMMEDIATE")
11876 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11877 let prior_schema = std::mem::replace(&mut self.schema, new_schema);
11878 let prior_subs = self.subs.clone();
11884 let prior_outbox = self.outbox.clone();
11885 let prior_rejections = self.rejections.clone();
11886 let prior_upgrading = self.upgrading;
11887 let prior_stopped = self.stopped;
11888 let prior_schema_floor = self.schema_floor.clone();
11889 let prior_overlay_dirty = self.overlay_dirty.snapshot();
11890 let prior_sync_needed = self.sync_needed;
11891 let prior_active_round = self.active_round;
11892 let prior_changes = self.change_queue.len();
11893 let prior_intents = self.sync_intent_queue.len();
11894 let prior_last_change = self.last_change.clone();
11895 let guarded = (|| -> Result<(), String> {
11896 let marker = read_local_schema_version(&self.conn, self.schema.version)?;
11901 self.create_bookkeeping_tables()?;
11902 match marker {
11903 Some(version) if version < self.schema.version => self.run_schema_reset()?,
11904 None => {
11905 self.create_synced_tables()?;
11906 self.persist_schema_version()?;
11907 }
11908 _ => {}
11909 }
11910 self.outbox = self.load_outbox()?;
11915 self.drop_unencodable_outbox(false, None)?;
11920 if marker == Some(self.schema.version)
11921 && (!self.outbox.is_empty()
11922 || !self.failed_commits.is_empty()
11923 || self.has_acknowledged_rows())
11924 {
11925 self.overlay_dirty.set(true);
11926 self.rebuild_overlay()?;
11927 }
11928 self.prune_unknown_subscriptions(false)?;
11929 self.save_subscription_scope_schema();
11930 self.enqueue_startup_sync_if_needed()?;
11934 Ok(())
11935 })();
11936 let outcome = match guarded {
11937 Ok(()) => self
11938 .conn
11939 .execute_batch("COMMIT")
11940 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
11941 Err(error) => Err(error),
11942 };
11943 if let Err(error) = outcome {
11944 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
11945 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
11947 }
11948 self.schema = prior_schema;
11949 self.subs = prior_subs;
11950 self.outbox = prior_outbox;
11951 self.rejections = prior_rejections;
11952 self.upgrading = prior_upgrading;
11953 self.stopped = prior_stopped;
11954 self.schema_floor = prior_schema_floor;
11955 self.overlay_dirty.restore(prior_overlay_dirty);
11956 self.sync_needed = prior_sync_needed;
11957 self.active_round = prior_active_round;
11958 self.change_queue.truncate(prior_changes);
11959 self.sync_intent_queue.truncate(prior_intents);
11960 self.last_change = prior_last_change;
11961 self.insert_sql.borrow_mut().clear();
11963 return Err(error);
11964 }
11965 Ok(())
11966 }
11967
11968 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
11969 let startup_work = !self.stopped
11970 && (!self.outbox.is_empty()
11971 || self.has_acknowledged_rows()
11972 || !self.load_pending_evictions()?.is_empty()
11973 || self.subs.iter().any(|sub| sub.state == SubState::Active));
11974 if startup_work {
11975 self.sync_needed = true;
11976 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11977 }
11978 Ok(())
11979 }
11980
11981 fn clear_satisfied_persisted_schema_floor(&mut self) {
11988 let satisfied = self
11989 .schema_floor
11990 .as_ref()
11991 .and_then(|floor| floor.required_schema_version)
11992 .is_some_and(|required| self.schema.version >= required);
11993 if !satisfied {
11994 return;
11995 }
11996 self.schema_floor = None;
11997 self.stopped = false;
11998 self.delete_meta(SCHEMA_FLOOR_KEY);
11999 }
12000
12001 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
12006 let Some(replica_path) = self.previous_version_replica_path() else {
12007 return Ok(());
12008 };
12009 let max_age_ms = self
12010 .previous_version
12011 .filter(|config| config.enabled)
12012 .map(|config| config.max_age_ms);
12013 reconcile_previous_version_at_boot(
12014 &self.conn,
12015 &replica_path,
12016 self.schema.version,
12017 max_age_ms,
12018 self.clock_now_ms(),
12019 )
12020 }
12021
12022 fn previous_version_replica_path(&self) -> Option<String> {
12026 let path = self.conn.path()?;
12027 if path.is_empty() || path.starts_with("file:") {
12028 return None;
12029 }
12030 Some(path.to_owned())
12031 }
12032
12033 fn run_schema_reset(&mut self) -> Result<(), String> {
12039 self.begin_observation("syncular_schema_reset")?;
12040 let prior_subs = self.subs.clone();
12041 let prior_outbox = self.outbox.clone();
12042 let prior_rejections = self.rejections.clone();
12043 let prior_upgrading = self.upgrading;
12044 let prior_stopped = self.stopped;
12045 let prior_schema_floor = self.schema_floor.clone();
12046 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12047 let prior_sync_needed = self.sync_needed;
12048 let prior_active_round = self.active_round;
12049 let mut batch = ChangeAccumulator::default();
12050 let result = self
12051 .run_schema_reset_observed(&mut batch, true)
12052 .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
12053 if let Err(error) = result {
12054 self.rollback_observation("syncular_schema_reset");
12055 self.subs = prior_subs;
12056 self.outbox = prior_outbox;
12057 self.rejections = prior_rejections;
12058 self.upgrading = prior_upgrading;
12059 self.stopped = prior_stopped;
12060 self.schema_floor = prior_schema_floor;
12061 self.overlay_dirty.restore(prior_overlay_dirty);
12062 self.sync_needed = prior_sync_needed;
12063 self.active_round = prior_active_round;
12064 return Err(error);
12065 }
12066 Ok(())
12067 }
12068
12069 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
12070 if self.get_meta(LOG_EPOCH_KEY).is_none() {
12071 self.begin_observation("syncular_epoch_acquisition")?;
12072 let previous_sync_needed = self.sync_needed;
12073 if let Err(error) = self.conn.execute(
12074 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12075 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
12076 ) {
12077 self.rollback_observation("syncular_epoch_acquisition");
12078 return Err(Self::sqlite_failure(&self.storage_failure, error));
12079 }
12080 self.sync_needed = true;
12081 let batch = ChangeAccumulator {
12082 status: true,
12083 ..ChangeAccumulator::default()
12084 };
12085 if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
12086 self.rollback_observation("syncular_epoch_acquisition");
12087 self.sync_needed = previous_sync_needed;
12088 return Err(error);
12089 }
12090 self.sync_intent_queue.push_back(SyncIntent::Interactive);
12091 return Ok(Vec::new());
12092 }
12093 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
12094 self.begin_observation("syncular_log_epoch_reset")?;
12095 let prior_subs = self.subs.clone();
12096 let prior_outbox = self.outbox.clone();
12097 let prior_rejections = self.rejections.clone();
12098 let prior_upgrading = self.upgrading;
12099 let prior_stopped = self.stopped;
12100 let prior_schema_floor = self.schema_floor.clone();
12101 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12102 let prior_sync_needed = self.sync_needed;
12103 let prior_active_round = self.active_round;
12104 let mut batch = ChangeAccumulator::default();
12105 let result = self
12106 .run_schema_reset_observed(&mut batch, false)
12107 .and_then(|()| {
12108 self.conn
12109 .execute(
12110 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12111 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
12112 )
12113 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12114 self.sync_needed = true;
12115 batch.status = true;
12116 self.finish_observation("syncular_log_epoch_reset", batch)
12117 });
12118 if let Err(error) = result {
12119 self.rollback_observation("syncular_log_epoch_reset");
12120 self.subs = prior_subs;
12121 self.outbox = prior_outbox;
12122 self.rejections = prior_rejections;
12123 self.upgrading = prior_upgrading;
12124 self.stopped = prior_stopped;
12125 self.schema_floor = prior_schema_floor;
12126 self.overlay_dirty.restore(prior_overlay_dirty);
12127 self.sync_needed = prior_sync_needed;
12128 self.active_round = prior_active_round;
12129 return Err(error);
12130 }
12131 self.sync_intent_queue.push_back(SyncIntent::Interactive);
12132 Ok(resets)
12133 }
12134
12135 fn run_schema_reset_observed(
12136 &mut self,
12137 batch: &mut ChangeAccumulator,
12138 capture: bool,
12139 ) -> Result<(), String> {
12140 read_local_schema_version(&self.conn, self.schema.version)?;
12146 self.conn
12147 .execute_batch(
12148 "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
12149 )
12150 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12151 self.cancel_sync_round();
12152 self.upgrading = true;
12153 batch.status = true;
12154 if self.previous_version.is_some_and(|config| config.enabled) {
12162 let previous_version = self
12163 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
12164 .and_then(|value| value.parse::<i32>().ok())
12165 .unwrap_or(self.schema.version);
12166 let pending = self
12167 .outbox
12168 .iter()
12169 .map(|commit| PendingCommitAudit {
12170 commit_id: &commit.client_commit_id,
12171 operations: commit_audit_operations(commit),
12172 })
12173 .collect::<Vec<_>>();
12174 crate::previous_version::write_previous_version_audit(
12175 &self.conn,
12176 &crate::previous_version::build_previous_version_audit(
12177 &self.schema,
12178 &pending,
12179 previous_version,
12180 self.schema.version,
12181 self.clock_now_ms(),
12182 ),
12183 );
12184 }
12185 if let Some(replica_path) = self.previous_version_replica_path() {
12190 sweep_previous_version_container(&self.conn, &replica_path)?;
12191 if capture {
12192 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
12193 let previous_version = self
12194 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
12195 .and_then(|value| value.parse::<i32>().ok())
12196 .unwrap_or(self.schema.version);
12197 capture_previous_version_from_replica(
12198 &self.conn,
12199 &replica_path,
12200 previous_version,
12201 self.schema.version,
12202 &config,
12203 self.clock_now_ms(),
12204 )?;
12205 }
12206 }
12207 }
12208 for table in &self.schema.tables {
12209 batch.table(&table.name);
12210 }
12211 for (base_key, unit, table) in self.load_registered_window_units() {
12212 batch.window(&base_key, &table, &unit);
12213 }
12214 if capture {
12215 self.prune_unknown_subscriptions(true)?;
12216 }
12217 self.insert_sql.borrow_mut().clear();
12219 self.overlay_dirty.set(true);
12220 let virtual_tables: Vec<String> = {
12228 let mut stmt = self
12229 .conn
12230 .prepare(
12231 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
12232 )
12233 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12234 let rows = stmt
12235 .query_map([], |row| row.get::<_, String>(0))
12236 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12237 rows.filter_map(Result::ok)
12238 .filter(|name| is_synced_table_name(name))
12239 .collect()
12240 };
12241 for name in virtual_tables {
12242 self.conn
12243 .execute_batch(&format!(
12244 "DROP TABLE IF EXISTS {}",
12245 quote_ident(&format!("_syncular_fts_{name}"))
12246 ))
12247 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12248 self.conn
12249 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
12250 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12251 }
12252 let existing: Vec<String> = {
12253 let mut stmt = self
12254 .conn
12255 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
12256 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12257 let rows = stmt
12258 .query_map([], |row| row.get::<_, String>(0))
12259 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12260 rows.filter_map(Result::ok)
12261 .filter(|name| is_synced_table_name(name))
12262 .collect()
12263 };
12264 for name in &existing {
12265 if let Some(table) = name.strip_prefix("_syncular_base_") {
12266 batch.table(table);
12267 } else if !name.starts_with("_syncular_") {
12268 batch.table(name);
12269 }
12270 }
12271 for name in existing {
12272 self.conn
12273 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
12274 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12275 }
12276 self.create_synced_tables()?;
12278 for sub in &mut self.subs {
12280 sub.cursor = -1;
12281 sub.bootstrap_state = None;
12282 sub.effective = None;
12283 sub.state = SubState::Active;
12284 sub.reason_code = None;
12285 sub.synced_once = false;
12286 }
12287 let subs = self.subs.clone();
12288 for sub in &subs {
12289 self.persist_sub(sub)?;
12290 }
12291 self.stopped = false;
12293 self.schema_floor = None;
12294 self.delete_meta(SCHEMA_FLOOR_KEY);
12295 self.persist_schema_version()?;
12297 self.save_subscription_scope_schema();
12298 self.drop_unencodable_outbox(false, Some(batch))?;
12309 self.rebuild_overlay()?;
12311 Ok(())
12312 }
12313
12314 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
12321 let incompatible = self
12322 .outbox
12323 .iter()
12324 .filter(|commit| {
12325 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
12326 })
12327 .cloned()
12328 .collect::<Vec<_>>();
12329 if incompatible.is_empty() {
12330 return Ok(false);
12331 }
12332 let prior_outbox = self.outbox.clone();
12338 let prior_rejections = self.rejections.clone();
12339 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12340 let owns_transaction = self.conn.is_autocommit();
12341 self.begin_observation("syncular_outbox_drop")?;
12342 let mut batch = ChangeAccumulator::default();
12343 let dropped = (|| -> Result<(), String> {
12344 let mut rejections = Vec::new();
12345 for commit in &incompatible {
12346 let results = commit
12347 .ops
12348 .iter()
12349 .enumerate()
12350 .map(|(op_index, operation)| {
12351 let rejection = RejectionRecord {
12352 client_commit_id: commit.client_commit_id.clone(),
12353 op_index: op_index as i32,
12354 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
12355 message: "the persisted commit cannot encode under the current schema"
12356 .to_owned(),
12357 retryable: false,
12358 details: None,
12359 operation: Some(CommitOperation::from(operation)),
12360 };
12361 rejections.push(rejection.clone());
12362 CommitOperationOutcome::Error { rejection }
12363 })
12364 .collect::<Vec<_>>();
12365 self.persist_commit_outcome(
12366 &commit.client_commit_id,
12367 CommitOutcomeStatus::Rejected,
12368 &results,
12369 Some(&commit.ops),
12370 )?;
12371 self.delete_outbox_persisted(&commit.client_commit_id)?;
12372 for operation in &commit.ops {
12375 if self.schema.table(&operation.table).is_some() {
12376 batch.table(&operation.table);
12377 }
12378 }
12379 }
12380 self.prune_commit_outcomes()?;
12381 let incompatible_ids = incompatible
12382 .iter()
12383 .map(|commit| commit.client_commit_id.as_str())
12384 .collect::<BTreeSet<_>>();
12385 self.outbox
12386 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
12387 self.rejections.extend(rejections);
12388 self.overlay_dirty.set(true);
12391 self.rebuild_overlay()?;
12392 batch.status = true;
12393 batch.rejections = true;
12394 batch.outcomes = true;
12395 Ok(())
12396 })();
12397 let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
12398 if let Err(error) = result {
12399 if owns_transaction {
12403 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12404 let mut failure = self.storage_failure.borrow_mut();
12405 let failure =
12406 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
12407 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
12408 }
12409 } else {
12410 self.rollback_observation("syncular_outbox_drop");
12411 }
12412 self.outbox = prior_outbox;
12413 self.rejections = prior_rejections;
12414 self.overlay_dirty.restore(prior_overlay_dirty);
12415 return Err(error);
12416 }
12417 Ok(true)
12418 }
12419
12420 fn create_synced_tables(&self) -> Result<(), String> {
12423 for table in &self.schema.tables {
12424 for (full, index_prefix) in [
12428 (base_table(&table.name), "_syncular_base_"),
12429 (visible_table(&table.name), ""),
12430 ] {
12431 let mut cols: Vec<String> =
12432 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
12433 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
12434 let sql = format!(
12435 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
12436 cols.join(", "),
12437 quote_ident(&table.primary_key)
12438 );
12439 self.conn
12440 .execute(&sql, [])
12441 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12442 for index in &table.indexes {
12446 let unique = if index.unique { "UNIQUE " } else { "" };
12447 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
12448 let cols_sql = index
12449 .columns
12450 .iter()
12451 .map(|c| quote_ident(c))
12452 .collect::<Vec<_>>()
12453 .join(", ");
12454 let index_sql = format!(
12455 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
12456 );
12457 self.conn
12458 .execute(&index_sql, [])
12459 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12460 }
12461 }
12462 }
12463 for table in &self.schema.tables {
12466 for index in &table.fts_indexes {
12467 self.create_fts_projection(table, index)?;
12468 }
12469 }
12470 Ok(())
12471 }
12472
12473 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
12474 let count: i64 = self
12475 .conn
12476 .query_row(
12477 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
12478 rusqlite::params![index.name],
12479 |row| row.get(0),
12480 )
12481 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12482 Ok(count > 0)
12483 }
12484
12485 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
12486 for suffix in ["ai", "ad", "au"] {
12487 self.conn
12488 .execute(
12489 &format!(
12490 "DROP TRIGGER IF EXISTS {}",
12491 quote_ident(&format!("{}_{suffix}", index.name))
12492 ),
12493 [],
12494 )
12495 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12496 }
12497 Ok(())
12498 }
12499
12500 fn create_fts_triggers(
12501 &self,
12502 table: &TableSchema,
12503 index: &FtsIndexSchema,
12504 ) -> Result<(), String> {
12505 let fts = quote_ident(&index.name);
12506 let source = visible_table(&table.name);
12507 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
12508 let pk = quote_ident(&table.primary_key);
12509 let projection_columns = std::iter::once(source_id.clone())
12510 .chain(index.columns.iter().map(|column| quote_ident(column)))
12511 .collect::<Vec<_>>()
12512 .join(", ");
12513 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
12514 .chain(
12515 index
12516 .columns
12517 .iter()
12518 .map(|column| format!("new.{}", quote_ident(column))),
12519 )
12520 .collect::<Vec<_>>()
12521 .join(", ");
12522 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12523 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)");
12524 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)");
12525 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})");
12526 let bi = quote_ident(&format!("{}_bi", index.name));
12527 let ai = quote_ident(&format!("{}_ai", index.name));
12528 let ad = quote_ident(&format!("{}_ad", index.name));
12529 let au = quote_ident(&format!("{}_au", index.name));
12530 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
12531 let sql = format!(
12532 "DROP TRIGGER IF EXISTS {bi};
12533 DROP TRIGGER IF EXISTS {ai};
12534 DROP TRIGGER IF EXISTS {ad};
12535 DROP TRIGGER IF EXISTS {au};
12536 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
12537 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
12538 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
12539 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
12540 );
12541 self.conn
12542 .execute_batch(&sql)
12543 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12544 }
12545
12546 fn rebuild_fts_projection(
12547 &self,
12548 table: &TableSchema,
12549 index: &FtsIndexSchema,
12550 ) -> Result<(), String> {
12551 let fts = quote_ident(&index.name);
12552 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
12553 let pk = quote_ident(&table.primary_key);
12554 let indexed_columns = index
12555 .columns
12556 .iter()
12557 .map(|column| quote_ident(column))
12558 .collect::<Vec<_>>();
12559 let projection_columns = std::iter::once(source_id.clone())
12560 .chain(indexed_columns.iter().cloned())
12561 .collect::<Vec<_>>()
12562 .join(", ");
12563 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12564 let sql = format!(
12565 "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};",
12566 columns = indexed_columns.join(", "),
12567 source = visible_table(&table.name),
12568 );
12569 self.conn
12570 .execute_batch(&sql)
12571 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12572 }
12573
12574 fn create_fts_projection(
12575 &self,
12576 table: &TableSchema,
12577 index: &FtsIndexSchema,
12578 ) -> Result<(), String> {
12579 self.begin_observation("syncular_fts_schema")?;
12580 let result = (|| {
12581 let existed = self.fts_projection_exists(index)?;
12582 let tokenizer = index.tokenize.replace('\'', "''");
12583 let columns = index
12584 .columns
12585 .iter()
12586 .map(|column| quote_ident(column))
12587 .collect::<Vec<_>>()
12588 .join(", ");
12589 let sql = format!(
12590 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
12591 fts = quote_ident(&index.name),
12592 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
12593 );
12594 self.conn.execute(&sql, []).map_err(|error| {
12595 format!(
12596 "cannot create local FTS5 projection {:?}: {error}",
12597 index.name
12598 )
12599 })?;
12600 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12601 let mapped: bool = self
12602 .conn
12603 .query_row(
12604 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
12605 [format!("_syncular_fts_{}", index.name)],
12606 |row| row.get(0),
12607 )
12608 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12609 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))?;
12610 if !mapped && existed {
12611 self.conn
12612 .execute_batch(&format!(
12613 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
12614 quote_ident(FTS_SOURCE_ID_COLUMN),
12615 quote_ident(&index.name)
12616 ))
12617 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12618 }
12619 self.create_fts_triggers(table, index)?;
12620 if !existed {
12621 self.rebuild_fts_projection(table, index)?;
12622 }
12623 Ok(())
12624 })();
12625 if let Err(error) = result {
12626 self.rollback_observation("syncular_fts_schema");
12627 return Err(error);
12628 }
12629 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
12630 self.rollback_observation("syncular_fts_schema");
12631 return Err(error.to_string());
12632 }
12633 Ok(())
12634 }
12635
12636 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
12639 let state = serde_json::json!({
12640 "requested": scope_map_to_json(&sub.requested),
12641 "params": sub.params,
12642 "cursor": sub.cursor,
12643 "bootstrapState": sub.bootstrap_state,
12644 "status": sub.state.name(),
12645 "reasonCode": sub.reason_code,
12646 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
12647 "syncedOnce": sub.synced_once,
12648 });
12649 self.conn.execute(
12650 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
12651 rusqlite::params![sub.id, sub.table, state.to_string()],
12652 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12653 }
12654
12655 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
12656 let ops: Vec<Value> = commit
12657 .ops
12658 .iter()
12659 .map(|op| {
12660 serde_json::json!({
12661 "op": if op.upsert { "upsert" } else { "delete" },
12662 "table": op.table,
12663 "rowId": op.row_id,
12664 "baseVersion": op.base_version,
12665 "values": op.values.clone().map(Value::Object),
12666 })
12667 })
12668 .collect();
12669 self.conn
12670 .execute(
12671 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
12672 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
12673 )
12674 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12675 for operation in &commit.ops {
12676 let Some(values) = &operation.values else {
12677 continue;
12678 };
12679 let Some(table) = self.schema.table(&operation.table) else {
12680 continue;
12681 };
12682 for column in table
12683 .columns
12684 .iter()
12685 .filter(|column| column.ty == ColumnType::BlobRef)
12686 {
12687 let Some(Value::String(raw)) = values.get(&column.name) else {
12688 continue;
12689 };
12690 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
12691 continue;
12692 };
12693 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
12694 continue;
12695 };
12696 self.conn
12697 .execute(
12698 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
12699 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
12700 rusqlite::params![commit.client_commit_id, blob_id],
12701 )
12702 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12703 }
12704 }
12705 Ok(())
12706 }
12707
12708 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
12709 if let Some(commit) = self
12710 .outbox
12711 .iter()
12712 .find(|commit| commit.client_commit_id == client_commit_id)
12713 {
12714 for operation in &commit.ops {
12715 self.overlay_dirty.table(&operation.table);
12716 }
12717 }
12718
12719 self.conn
12720 .execute(
12721 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
12722 rusqlite::params![client_commit_id],
12723 )
12724 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12725 self.conn
12726 .execute(
12727 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
12728 rusqlite::params![client_commit_id],
12729 )
12730 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12731 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)", [])
12732 .map(|_| ())
12733 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12734 }
12735
12736 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
12737 match status {
12738 CommitOutcomeStatus::Applied => "applied",
12739 CommitOutcomeStatus::Cached => "cached",
12740 CommitOutcomeStatus::Conflict => "conflict",
12741 CommitOutcomeStatus::Rejected => "rejected",
12742 }
12743 }
12744
12745 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
12746 match resolution {
12747 CommitOutcomeResolution::Active => "active",
12748 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
12749 CommitOutcomeResolution::Superseded => "superseded",
12750 CommitOutcomeResolution::Dismissed => "dismissed",
12751 }
12752 }
12753
12754 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
12755 match value {
12756 "applied" => Ok(CommitOutcomeStatus::Applied),
12757 "cached" => Ok(CommitOutcomeStatus::Cached),
12758 "conflict" => Ok(CommitOutcomeStatus::Conflict),
12759 "rejected" => Ok(CommitOutcomeStatus::Rejected),
12760 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
12761 }
12762 }
12763
12764 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
12765 match value {
12766 "active" => Ok(CommitOutcomeResolution::Active),
12767 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
12768 "superseded" => Ok(CommitOutcomeResolution::Superseded),
12769 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
12770 _ => Err(format!(
12771 "invalid persisted commit outcome resolution {value:?}"
12772 )),
12773 }
12774 }
12775
12776 fn persist_commit_outcome(
12777 &self,
12778 client_commit_id: &str,
12779 status: CommitOutcomeStatus,
12780 results: &[CommitOperationOutcome],
12781 operations: Option<&[OutboxOp]>,
12782 ) -> Result<(), String> {
12783 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
12784 let operations_json = operations
12785 .map(|items| {
12786 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
12787 })
12788 .transpose()
12789 .map_err(|error| error.to_string())?;
12790 self.conn
12791 .execute(
12792 "INSERT INTO _syncular_commit_outcomes (
12793 client_commit_id, status, recorded_at_ms, results_json,
12794 operations_json, resolution
12795 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
12796 rusqlite::params![
12797 client_commit_id,
12798 Self::outcome_status_name(status),
12799 self.clock_now_ms(),
12800 results_json,
12801 operations_json
12802 ],
12803 )
12804 .map(|_| ())
12805 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12806 }
12807
12808 fn stored_cell_is_representable(cell: &Value) -> bool {
12811 cell.is_null()
12812 || cell.is_string()
12813 || cell.is_boolean()
12814 || cell.is_number()
12815 || is_canonical_bytes_envelope(cell)
12816 }
12817
12818 fn stored_row_is_representable(value: &Value) -> bool {
12819 value
12820 .as_object()
12821 .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
12822 }
12823
12824 fn stored_operation_is_representable(value: &Value) -> bool {
12825 value.get("table").is_some_and(Value::is_string)
12826 && value.get("rowId").is_some_and(Value::is_string)
12827 && value.get("baseVersion").is_none_or(|v| {
12828 v.as_i64()
12829 .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
12830 })
12831 && match value.get("op").and_then(Value::as_str) {
12832 Some("upsert") => value
12833 .get("values")
12834 .is_some_and(Self::stored_row_is_representable),
12835 Some("delete") => value.get("values").is_none(),
12836 _ => false,
12837 }
12838 }
12839
12840 fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
12844 serde_json::to_value(CommitOperation::from(operation))
12845 .is_ok_and(|value| Self::stored_operation_is_representable(&value))
12846 }
12847
12848 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
12849 let StoredCommitOutcomeRow {
12850 sequence,
12851 client_commit_id,
12852 status,
12853 recorded_at_ms,
12854 results_json,
12855 operations_json,
12856 resolution,
12857 resolved_at_ms,
12858 replacement_client_commit_id,
12859 } = row;
12860 if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
12861 || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
12862 || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
12863 {
12864 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
12865 }
12866 let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
12867 let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
12868 for result in &stored {
12869 let nested = match result.get("status").and_then(Value::as_str) {
12870 Some("conflict") => result.get("conflict"),
12871 Some("error") => result.get("rejection"),
12872 _ => None,
12873 };
12874 if nested
12875 .and_then(|record| record.get("operation"))
12876 .is_some_and(|op| !Self::stored_operation_is_representable(op))
12877 || (result.get("status").and_then(Value::as_str) == Some("conflict")
12878 && nested
12879 .and_then(|record| record.get("serverRow"))
12880 .is_some_and(|row| !Self::stored_row_is_representable(row)))
12881 {
12882 return Err(invalid());
12883 }
12884 }
12885 let results: Vec<CommitOperationOutcome> =
12886 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
12887 let has_conflict = results
12888 .iter()
12889 .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
12890 let has_error = results
12891 .iter()
12892 .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
12893 let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
12894 let redacted = results.is_empty() && operations_json.is_none();
12895 if match status {
12896 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
12897 CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
12898 CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
12899 } {
12900 return Err(invalid());
12901 }
12902 let operations: Option<Vec<CommitOperation>> = operations_json
12903 .map(|raw| {
12904 let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
12905 if !stored.iter().all(Self::stored_operation_is_representable) {
12906 return Err(invalid());
12907 }
12908 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
12909 })
12910 .transpose()?;
12911 for result in &results {
12912 match result {
12913 CommitOperationOutcome::Conflict { conflict }
12914 if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
12915 {
12916 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
12917 }
12918 CommitOperationOutcome::Error { rejection } => {
12919 if let Some(details) = &rejection.details {
12920 let encoded = serde_json::to_string(details).map_err(|_| {
12921 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
12922 })?;
12923 RejectionDetails::parse(&encoded).map_err(|_| {
12924 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
12925 })?;
12926 }
12927 }
12928 _ => {}
12929 }
12930 }
12931 Ok(CommitOutcome {
12932 sequence,
12933 client_commit_id,
12934 status,
12935 recorded_at_ms,
12936 results,
12937 operations,
12938 retained_rows: None,
12939 resolution: Self::parse_outcome_resolution(&resolution)?,
12940 resolved_at_ms,
12941 replacement_client_commit_id,
12942 })
12943 }
12944
12945 fn unique_conflicts(
12947 &self,
12948 table: &TableSchema,
12949 values: &Map<String, Value>,
12950 base: bool,
12951 ) -> Result<Vec<RetainedUniqueConflict>, String> {
12952 let source = if base {
12953 base_table(&table.name)
12954 } else {
12955 visible_table(&table.name)
12956 };
12957 let mut conflicts = Vec::new();
12958 for index in table.indexes.iter().filter(|index| index.unique) {
12959 let mut params = vec![json_param_to_sql(
12960 values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
12961 )?];
12962 for name in &index.columns {
12963 let column = table
12964 .columns
12965 .iter()
12966 .find(|column| column.name == *name)
12967 .ok_or("sync.local_corrupt")?;
12968 let cell = json_to_column_value(column, values.get(name))?;
12969 params.push(owned_sql_value(RowParam::Cell(&cell))?);
12970 }
12971 let sql = format!(
12972 "SELECT * FROM {source} WHERE {} != ? AND {}",
12973 quote_ident(&table.primary_key),
12974 index
12975 .columns
12976 .iter()
12977 .map(|column| format!("{} = ?", quote_ident(column)))
12978 .collect::<Vec<_>>()
12979 .join(" AND ")
12980 );
12981 let mut statement = self
12982 .conn
12983 .prepare_cached(&sql)
12984 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12985 let records = statement
12986 .query_map(rusqlite::params_from_iter(params), |record| {
12987 let values = table
12988 .columns
12989 .iter()
12990 .enumerate()
12991 .map(|(index, column)| {
12992 let value = sql_ref_to_json(column, record.get_ref(index)?);
12993 json_to_column_value(column, Some(&value)).map_err(|message| {
12994 rusqlite::Error::ToSqlConversionFailure(message.into())
12995 })
12996 })
12997 .collect::<Result<Row, _>>()?;
12998 Ok((values, record.get::<_, i64>(table.columns.len())?))
12999 })
13000 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13001 for record in records {
13002 let (row, version) =
13003 record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13004 let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
13005 conflicts.push(RetainedUniqueConflict {
13006 index: index.name.clone(),
13007 columns: index.columns.clone(),
13008 row_id,
13009 server_row: table
13010 .columns
13011 .iter()
13012 .zip(&row)
13013 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
13014 .collect(),
13015 server_version: version,
13016 });
13017 }
13018 }
13019 Ok(conflicts)
13020 }
13021
13022 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
13023 if let Some((commit, initial)) = self
13024 .failed_commits
13025 .iter()
13026 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
13027 {
13028 let mut rows = Vec::new();
13029 for operation in &commit.ops {
13030 let table = self
13031 .schema
13032 .table(&operation.table)
13033 .ok_or_else(|| "sync.unknown_table".to_owned())?;
13034 let key =
13035 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
13036 .map_err(|_| "sync.local_corrupt".to_owned())?;
13037 let base = self.stored_row(table, &operation.row_id, true)?;
13038 rows.push(RetainedCommitRow {
13039 table: operation.table.clone(),
13040 row_id: operation.row_id.clone(),
13041 unique_conflicts: if operation.upsert {
13042 self.unique_conflicts(
13043 table,
13044 initial
13045 .get(&key)
13046 .and_then(Value::as_object)
13047 .or(operation.values.as_ref())
13048 .ok_or("sync.local_corrupt")?,
13049 true,
13050 )?
13051 } else {
13052 Vec::new()
13053 },
13054 local_row: if operation.upsert {
13055 initial
13056 .get(&key)
13057 .and_then(Value::as_object)
13058 .cloned()
13059 .or_else(|| operation.values.clone())
13060 } else {
13061 None
13062 },
13063 server_row: base.as_ref().map(|(values, _)| {
13064 table
13065 .columns
13066 .iter()
13067 .zip(values)
13068 .map(|(column, value)| {
13069 (column.name.clone(), column_value_to_json(value))
13070 })
13071 .collect()
13072 }),
13073 server_version: base.map(|(_, version)| version),
13074 });
13075 }
13076 outcome.retained_rows = Some(rows);
13077 }
13078 Ok(outcome)
13079 }
13080
13081 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
13082 let row = self
13083 .conn
13084 .query_row(
13085 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
13086 resolution, resolved_at_ms, replacement_client_commit_id
13087 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
13088 rusqlite::params![client_commit_id],
13089 |row| {
13090 Ok(StoredCommitOutcomeRow {
13091 sequence: row.get(0)?,
13092 client_commit_id: row.get(1)?,
13093 status: row.get(2)?,
13094 recorded_at_ms: row.get(3)?,
13095 results_json: row.get(4)?,
13096 operations_json: row.get(5)?,
13097 resolution: row.get(6)?,
13098 resolved_at_ms: row.get(7)?,
13099 replacement_client_commit_id: row.get(8)?,
13100 })
13101 },
13102 )
13103 .optional()
13104 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13105 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
13106 .transpose()
13107 }
13108
13109 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
13110 if query.limit == Some(0) {
13111 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
13112 }
13113 let mut sql = String::from(
13114 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
13115 resolution, resolved_at_ms, replacement_client_commit_id
13116 FROM _syncular_commit_outcomes",
13117 );
13118 if query.active_only {
13119 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
13120 }
13121 sql.push_str(" ORDER BY seq DESC");
13122 if let Some(limit) = query.limit {
13123 sql.push_str(&format!(" LIMIT {limit}"));
13124 }
13125 let mut stmt = self
13126 .conn
13127 .prepare(&sql)
13128 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13129 let rows = stmt
13130 .query_map([], |row| {
13131 Ok(StoredCommitOutcomeRow {
13132 sequence: row.get(0)?,
13133 client_commit_id: row.get(1)?,
13134 status: row.get(2)?,
13135 recorded_at_ms: row.get(3)?,
13136 results_json: row.get(4)?,
13137 operations_json: row.get(5)?,
13138 resolution: row.get(6)?,
13139 resolved_at_ms: row.get(7)?,
13140 replacement_client_commit_id: row.get(8)?,
13141 })
13142 })
13143 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13144 let mut outcomes = Vec::new();
13145 for row in rows {
13146 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
13147 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
13148 )?)?);
13149 }
13150 Ok(outcomes)
13151 }
13152
13153 fn prune_commit_outcomes(&self) -> Result<(), String> {
13154 #[cfg(test)]
13155 self.outcome_prune_count
13156 .set(self.outcome_prune_count.get() + 1);
13157 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
13158 let count = self
13159 .conn
13160 .query_row(
13161 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
13162 [],
13163 |row| row.get::<_, i64>(0),
13164 )
13165 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13166 as usize;
13167 let excess = count.saturating_sub(max_entries);
13168 if excess == 0 {
13169 return Ok(());
13170 }
13171 let mut stmt = self
13172 .conn
13173 .prepare(
13174 "SELECT seq FROM _syncular_commit_outcomes
13175 WHERE status IN ('applied', 'cached') OR resolution != 'active'
13176 ORDER BY seq ASC LIMIT ?1",
13177 )
13178 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13179 let rows = stmt
13180 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
13181 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13182 let sequences = rows
13183 .collect::<Result<Vec<_>, _>>()
13184 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13185 drop(stmt);
13186 for sequence in sequences {
13187 self.conn
13188 .execute(
13189 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
13190 rusqlite::params![sequence],
13191 )
13192 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13193 }
13194 Ok(())
13195 }
13196
13197 pub fn subscribe(
13200 &mut self,
13201 id: String,
13202 table: String,
13203 scopes: Vec<(String, Vec<String>)>,
13204 params: Option<String>,
13205 ) -> Result<(), String> {
13206 if self.schema.table(&table).is_none() {
13207 return Err(format!("unknown table {table:?}"));
13208 }
13209 let registered_at = self
13210 .subs
13211 .iter()
13212 .position(|subscription| subscription.id == id);
13213 if let Some(index) = registered_at {
13214 let existing = &self.subs[index];
13215 let same_intent = existing.table == table
13216 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
13217 && existing.params == params;
13218 if !same_intent {
13219 return Err(
13220 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
13221 .to_owned(),
13222 );
13223 }
13224 if existing.state != SubState::Revoked {
13229 return Ok(());
13230 }
13231 }
13232 let sub = Subscription {
13233 id: id.clone(),
13234 table,
13235 requested: scopes,
13236 params,
13237 cursor: -1,
13238 bootstrap_state: None,
13239 state: SubState::Active,
13240 reason_code: None,
13241 effective: None,
13242 synced_once: false,
13243 };
13244 self.persist_sub(&sub)?;
13245 match registered_at {
13246 Some(index) => self.subs[index] = sub,
13247 None => self.subs.push(sub),
13248 }
13249 if registered_at.is_some() {
13253 self.set_sync_needed(true, true);
13254 }
13255 Ok(())
13256 }
13257
13258 pub fn unsubscribe(&mut self, id: &str) {
13259 if self.subs.iter().any(|s| s.id == id) {
13260 self.cancel_sync_round();
13261 }
13262 self.subs.retain(|s| s.id != id);
13263 let _ = self.conn.execute(
13264 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
13265 rusqlite::params![id],
13266 );
13267 }
13268
13269 pub fn set_window(
13277 &mut self,
13278 base: &WindowBase,
13279 units: &[String],
13280 ) -> Result<CommandEffects, String> {
13281 let table = self
13282 .schema
13283 .table(&base.table)
13284 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
13285 if table.scope_column(&base.variable).is_none() {
13286 return Err(format!(
13287 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
13288 base.table, base.variable
13289 ));
13290 }
13291 let base_key = window_base_key(base);
13292 let wanted: std::collections::HashSet<&String> = units.iter().collect();
13293 let live = self.load_window_units(&base_key);
13294 self.begin_observation("syncular_window")?;
13295 let mut batch = ChangeAccumulator::default();
13296 let mut changed = false;
13297
13298 let prior_subs = self.subs.clone();
13299 let widened = (|| {
13300 for unit in units {
13302 if live.iter().any(|(u, _)| u == unit) {
13303 continue;
13304 }
13305 let sub_id = derive_sub_id(base, unit);
13306 self.delete_pending_evict(&sub_id)?;
13307 self.insert_window_unit(&base_key, unit, &sub_id)?;
13308 self.subscribe(
13309 sub_id,
13310 base.table.clone(),
13311 unit_scopes(base, unit),
13312 base.params.clone(),
13313 )?;
13314 batch.window(&base_key, &base.table, unit);
13315 changed = true;
13316 }
13317
13318 self.finish_observation("syncular_window", batch)
13319 })();
13320 if let Err(error) = widened {
13321 self.rollback_observation("syncular_window");
13322 self.subs = prior_subs;
13323 return Err(error);
13324 }
13325 for (unit, sub_id) in live {
13326 if wanted.contains(&unit) {
13327 continue;
13328 }
13329 self.rebuild_overlay_if_dirty()?;
13330 let prior_subs = self.subs.clone();
13331 self.begin_observation("syncular_window")?;
13332 let effective = self
13333 .subs
13334 .iter()
13335 .find(|sub| sub.id == sub_id)
13336 .and_then(|sub| sub.effective.clone())
13337 .unwrap_or_else(|| unit_scopes(base, &unit));
13338 let mut batch = ChangeAccumulator::default();
13339 self.record_scope_map(&mut batch, &base.table, &effective);
13340 batch.window(&base_key, &base.table, &unit);
13341 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
13342 let remaining = match result {
13343 Ok(remaining) => remaining,
13344 Err(error) => {
13345 self.rollback_observation("syncular_window");
13346 self.subs = prior_subs;
13347 return Err(error);
13348 }
13349 };
13350 if let Err(error) = self.finish_observation("syncular_window", batch) {
13351 self.rollback_observation("syncular_window");
13352 self.subs = prior_subs;
13353 return Err(error);
13354 }
13355 if remaining {
13356 self.drain_pending_evictions()?;
13357 }
13358 changed = true;
13359 }
13360 Ok(if changed {
13361 CommandEffects::interactive()
13362 } else {
13363 CommandEffects::none()
13364 })
13365 }
13366
13367 pub fn window_state(&self, base: &WindowBase) -> WindowState {
13372 let mut units = Vec::new();
13373 let mut pending = Vec::new();
13374 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
13375 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
13376 Some(sub) => {
13377 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
13378 }
13379 None => true,
13380 };
13381 if is_pending {
13382 pending.push(unit.clone());
13383 }
13384 units.push(unit);
13385 }
13386 WindowState { units, pending }
13387 }
13388
13389 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
13390 let mut stmt = match self
13391 .conn
13392 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
13393 {
13394 Ok(stmt) => stmt,
13395 Err(_) => return Vec::new(),
13396 };
13397 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
13398 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
13399 });
13400 match rows {
13401 Ok(rows) => rows.filter_map(Result::ok).collect(),
13402 Err(_) => Vec::new(),
13403 }
13404 }
13405
13406 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
13407 let mut stmt = match self.conn.prepare(
13408 "SELECT windows.base, windows.unit, subscriptions.tbl
13409 FROM _syncular_windows AS windows
13410 JOIN _syncular_subscriptions AS subscriptions
13411 ON subscriptions.id = windows.sub_id
13412 ORDER BY windows.base, windows.unit",
13413 ) {
13414 Ok(stmt) => stmt,
13415 Err(_) => return Vec::new(),
13416 };
13417 let rows = stmt.query_map([], |row| {
13418 Ok((
13419 row.get::<_, String>(0)?,
13420 row.get::<_, String>(1)?,
13421 row.get::<_, String>(2)?,
13422 ))
13423 });
13424 match rows {
13425 Ok(rows) => rows.filter_map(Result::ok).collect(),
13426 Err(_) => Vec::new(),
13427 }
13428 }
13429
13430 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
13431 self.conn
13432 .query_row(
13433 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
13434 rusqlite::params![sub_id],
13435 |row| Ok((row.get(0)?, row.get(1)?)),
13436 )
13437 .ok()
13438 }
13439
13440 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
13441 self.conn
13442 .execute(
13443 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
13444 rusqlite::params![base_key, unit, sub_id],
13445 )
13446 .map(|_| ())
13447 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13448 }
13449
13450 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
13451 self.conn
13452 .execute(
13453 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
13454 rusqlite::params![base_key, unit],
13455 )
13456 .map(|_| ())
13457 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13458 }
13459
13460 fn evict_unit(
13467 &mut self,
13468 base_key: &str,
13469 base: &WindowBase,
13470 unit: &str,
13471 sub_id: &str,
13472 ) -> Result<bool, String> {
13473 let effective = self
13474 .subs
13475 .iter()
13476 .find(|s| s.id == sub_id)
13477 .and_then(|s| s.effective.clone())
13478 .unwrap_or_else(|| unit_scopes(base, unit));
13479 let pinned = self.pinned_row_ids(&base.table);
13480 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
13481 self.delete_window_unit(base_key, unit)?;
13482 self.conn
13483 .execute(
13484 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
13485 [sub_id],
13486 )
13487 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13488 self.subs.retain(|sub| sub.id != sub_id);
13489 if deferred {
13490 self.save_pending_evict(sub_id, &base.table, &effective)?;
13491 } else {
13492 self.delete_pending_evict(sub_id)?;
13493 }
13494 Ok(remaining)
13495 }
13496
13497 fn evict_scope_rows(
13501 &self,
13502 table_name: &str,
13503 effective: &[(String, Vec<String>)],
13504 pinned: &std::collections::HashSet<String>,
13505 ) -> Result<(bool, bool), String> {
13506 if effective.is_empty() {
13507 return Ok((false, false));
13508 }
13509 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
13510 let mut clauses = Vec::new();
13511 let mut params: Vec<SqlValue> = Vec::new();
13512 for (variable, values) in effective {
13513 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
13514 if values.is_empty() {
13515 return Ok((false, false));
13516 }
13517 let holes = values
13518 .iter()
13519 .map(|v| {
13520 params.push(SqlValue::Text(v.clone()));
13521 "?"
13522 })
13523 .collect::<Vec<_>>()
13524 .join(", ");
13525 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
13526 }
13527 let scope = clauses.join(" AND ");
13528 let scope_params = params.len();
13529 let pk = quote_ident(&table.primary_key);
13530 if !pinned.is_empty() {
13531 let holes = pinned
13532 .iter()
13533 .map(|id| {
13534 params.push(SqlValue::Text(id.clone()));
13535 "?"
13536 })
13537 .collect::<Vec<_>>()
13538 .join(", ");
13539 clauses.push(format!("{pk} NOT IN ({holes})"));
13540 }
13541 let predicate = clauses.join(" AND ");
13542 let base = base_table(table_name);
13543 let visible = visible_table(table_name);
13544 let mut ids: Vec<SqlValue> = Vec::new();
13545 for source in [&base, &visible] {
13546 let mut stmt = self
13547 .conn
13548 .prepare_cached(&format!(
13549 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
13550 ))
13551 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13552 ids = stmt
13553 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
13554 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13555 .collect::<Result<_, _>>()
13556 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13557 if !ids.is_empty() {
13558 break;
13559 }
13560 }
13561 if !ids.is_empty() {
13562 for id in &ids {
13563 self.conn
13564 .execute(
13565 "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
13566 rusqlite::params![table_name, id],
13567 )
13568 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13569 }
13570 let holes = vec!["?"; ids.len()].join(", ");
13571 for source in [&base, &visible] {
13572 self.conn
13573 .execute(
13574 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
13575 rusqlite::params_from_iter(&ids),
13576 )
13577 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13578 }
13579 }
13580 let mut remaining = false;
13581 let mut deferred = false;
13582 for source in [&base, &visible] {
13583 remaining |= self
13584 .conn
13585 .query_row(
13586 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
13587 rusqlite::params_from_iter(¶ms),
13588 |row| row.get::<_, bool>(0),
13589 )
13590 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13591 deferred |= self
13592 .conn
13593 .query_row(
13594 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
13595 rusqlite::params_from_iter(¶ms[..scope_params]),
13596 |row| row.get::<_, bool>(0),
13597 )
13598 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13599 }
13600 Ok((remaining, deferred))
13601 }
13602
13603 fn drain_pending_evictions(&mut self) -> Result<(), String> {
13605 let pending = self.load_pending_evictions()?;
13606 if pending.is_empty() {
13607 return Ok(());
13608 }
13609 self.rebuild_overlay_if_dirty()?;
13610 for (sub_id, table_name, effective) in pending {
13611 if self.schema.table(&table_name).is_none() {
13612 self.delete_pending_evict(&sub_id)?;
13613 continue;
13614 }
13615 loop {
13616 std::thread::yield_now();
13617 self.begin_observation("syncular_evict")?;
13618 let pinned = self.pinned_row_ids(&table_name);
13619 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
13620 let (remaining, deferred) = match result {
13621 Ok(result) => result,
13622 Err(error) => {
13623 self.rollback_observation("syncular_evict");
13624 return Err(error);
13625 }
13626 };
13627 if !deferred {
13628 if let Err(error) = self.delete_pending_evict(&sub_id) {
13629 self.rollback_observation("syncular_evict");
13630 return Err(error);
13631 }
13632 }
13633 let mut batch = ChangeAccumulator::default();
13634 self.record_scope_map(&mut batch, &table_name, &effective);
13635 if let Err(error) = self.finish_observation("syncular_evict", batch) {
13636 self.rollback_observation("syncular_evict");
13637 return Err(error);
13638 }
13639 if !remaining {
13640 break;
13641 }
13642 }
13643 }
13644 Ok(())
13645 }
13646
13647 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
13650 let mut pinned = std::collections::HashSet::new();
13651 for commit in self
13652 .outbox
13653 .iter()
13654 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
13655 {
13656 for op in &commit.ops {
13657 if op.table == table {
13658 pinned.insert(op.row_id.clone());
13659 }
13660 }
13661 }
13662 pinned
13663 }
13664
13665 fn save_pending_evict(
13666 &self,
13667 sub_id: &str,
13668 table: &str,
13669 effective: &[(String, Vec<String>)],
13670 ) -> Result<(), String> {
13671 self.conn.execute(
13672 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
13673 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
13674 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13675 }
13676
13677 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
13678 self.conn
13679 .execute(
13680 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
13681 rusqlite::params![sub_id],
13682 )
13683 .map(|_| ())
13684 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13685 }
13686
13687 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
13688 let mut stmt = self
13689 .conn
13690 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
13691 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13692 let rows = stmt
13693 .query_map([], |row| {
13694 Ok((
13695 row.get::<_, String>(0)?,
13696 row.get::<_, String>(1)?,
13697 row.get::<_, String>(2)?,
13698 ))
13699 })
13700 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13701 rows.map(|row| {
13702 let (sub_id, table, json) =
13703 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13704 let value: Value = serde_json::from_str(&json)
13705 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
13706 let effective = json_to_scope_map(&value)
13707 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
13708 Ok((sub_id, table, effective))
13709 })
13710 .collect()
13711 }
13712
13713 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
13715 self.retain_failed_commits = enabled;
13716 for (commit, _) in &self.failed_commits {
13717 for operation in &commit.ops {
13718 self.overlay_dirty.table(&operation.table);
13719 }
13720 }
13721 self.failed_commits = self.load_failed_commits()?;
13722 for (commit, _) in &self.failed_commits {
13723 for operation in &commit.ops {
13724 self.overlay_dirty.table(&operation.table);
13725 }
13726 }
13727 Ok(())
13728 }
13729
13730 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
13731 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
13732 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13733 let rows = statement
13734 .query_map([], |row| {
13735 Ok((
13736 row.get::<_, String>(0)?,
13737 row.get::<_, String>(1)?,
13738 row.get::<_, String>(2)?,
13739 ))
13740 })
13741 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13742 rows.map(|row| {
13743 let (id, operations, initial) =
13744 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13745 let operations: Vec<CommitOperation> =
13746 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
13747 let initial =
13748 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
13749 Ok((
13750 OutboxCommit {
13751 client_commit_id: id,
13752 ops: operations
13753 .into_iter()
13754 .map(|op| OutboxOp {
13755 upsert: op.op == "upsert",
13756 table: op.table,
13757 row_id: op.row_id,
13758 base_version: op.base_version,
13759 values: op.values,
13760 })
13761 .collect(),
13762 },
13763 initial,
13764 ))
13765 })
13766 .collect()
13767 }
13768
13769 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
13770 let mut initial = Map::new();
13771 for operation in operations {
13772 let table = self
13773 .schema
13774 .table(&operation.table)
13775 .ok_or_else(|| "sync.unknown_table".to_owned())?;
13776 let initial_row = match self.stored_row(table, &operation.row_id, true)? {
13777 Some(row) => Some(row),
13778 None => self.visible_row(table, &operation.row_id)?,
13779 };
13780 if let Some((values, _)) = initial_row {
13781 let mut intended: Map<String, Value> = table
13782 .columns
13783 .iter()
13784 .zip(values.iter())
13785 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
13786 .collect();
13787 if let Some(patch) = &operation.values {
13788 intended.extend(patch.clone());
13789 }
13790 initial.insert(
13791 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
13792 .map_err(|_| "sync.local_corrupt".to_owned())?,
13793 Value::Object(intended),
13794 );
13795 }
13796 }
13797 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
13798 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
13799 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
13800 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13801 self.failed_commits.push((
13802 OutboxCommit {
13803 client_commit_id: id.to_owned(),
13804 ops: operations.to_vec(),
13805 },
13806 initial,
13807 ));
13808 Ok(())
13809 }
13810
13811 fn drop_failed_commits(
13812 &mut self,
13813 matches: impl Fn(&str, &Map<String, Value>) -> bool,
13814 ) -> Result<Vec<OutboxCommit>, String> {
13815 let mut doomed = self.drop_acknowledged_rows(&matches)?;
13816 for (commit, initial) in &self.failed_commits {
13817 for operation in &commit.ops {
13818 let key =
13819 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
13820 .map_err(|_| "sync.local_corrupt".to_owned())?;
13821 if initial
13822 .get(&key)
13823 .and_then(Value::as_object)
13824 .is_some_and(|values| matches(&operation.table, values))
13825 {
13826 doomed.push(commit.clone());
13827 break;
13828 }
13829 }
13830 }
13831 for commit in &doomed {
13832 self.conn
13833 .execute(
13834 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
13835 rusqlite::params![commit.client_commit_id],
13836 )
13837 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13838 }
13839 for commit in &doomed {
13840 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))?;
13841 }
13842 self.conflicts.retain(|record| {
13843 !doomed
13844 .iter()
13845 .any(|commit| commit.client_commit_id == record.client_commit_id)
13846 });
13847 self.rejections.retain(|record| {
13848 !doomed
13849 .iter()
13850 .any(|commit| commit.client_commit_id == record.client_commit_id)
13851 });
13852 self.failed_commits.retain(|(commit, _)| {
13853 !doomed
13854 .iter()
13855 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
13856 });
13857 for commit in &doomed {
13858 for operation in &commit.ops {
13859 self.overlay_dirty.table(&operation.table);
13860 }
13861 }
13862 Ok(doomed)
13863 }
13864
13865 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
13869 let prior = self.storage_failure.borrow_mut().take();
13870 let outcome = (|| -> Result<String, String> {
13871 if mutations.is_empty() {
13872 return Err(
13873 "sync.invalid_request: a commit must contain at least one operation (§6.1)"
13874 .to_owned(),
13875 );
13876 }
13877 let mut ops = Vec::with_capacity(mutations.len());
13878 for mutation in mutations {
13879 match mutation {
13880 Mutation::Patch {
13881 table,
13882 values,
13883 base_version,
13884 } => {
13885 let schema_table = self
13886 .schema
13887 .table(&table)
13888 .ok_or_else(|| "sync.unknown_table".to_owned())?;
13889 let values = normalize_values_casing(schema_table, values)?;
13890 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
13891 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
13892 }
13893 Mutation::Upsert {
13894 table,
13895 values,
13896 base_version,
13897 } => {
13898 let schema_table = self.schema.table(&table).ok_or_else(|| {
13899 format!("sync.unknown_table: unknown table {table:?}")
13900 })?;
13901 let values = normalize_values_casing(schema_table, values)?;
13905 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
13906 let values = full_row_values(schema_table, values)?;
13909 self.validate_author_encode(schema_table, &row_id, &values)?;
13915 ops.push(OutboxOp {
13916 upsert: true,
13917 table,
13918 row_id,
13919 base_version,
13920 values: Some(values),
13921 });
13922 }
13923 Mutation::Delete {
13924 table,
13925 row_id,
13926 base_version,
13927 } => {
13928 if self.schema.table(&table).is_none() {
13929 return Err(format!("sync.unknown_table: unknown table {table:?}"));
13930 }
13931 ops.push(OutboxOp {
13932 upsert: false,
13933 table,
13934 row_id,
13935 base_version,
13936 values: None,
13937 });
13938 }
13939 }
13940 }
13941 self.record_outbox_commit(ops)
13942 })();
13943 self.finish_authoring(prior, outcome)
13944 }
13945
13946 fn finish_authoring(
13952 &self,
13953 prior: Option<QueryReadFailure>,
13954 outcome: Result<String, String>,
13955 ) -> Result<String, ClientError> {
13956 let fresh = self.storage_failure.borrow_mut().take();
13957 *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
13958 match outcome {
13959 Ok(id) => Ok(id),
13960 Err(message) => Err(fresh
13961 .map(ClientError::from)
13962 .unwrap_or_else(|| ClientError::from(message))),
13963 }
13964 }
13965
13966 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
13967 let commit = OutboxCommit {
13968 client_commit_id: uuid::Uuid::new_v4().to_string(),
13969 ops,
13970 };
13971 self.begin_observation("syncular_mutation")?;
13972 let mut batch = ChangeAccumulator::default();
13973 for op in &commit.ops {
13974 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
13975 if let Some(values) = &op.values {
13976 if let Some(table) = self.schema.table(&op.table) {
13977 for scope in &table.scope_variables {
13978 if let Some(Value::String(value)) = values.get(&scope.column) {
13979 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
13980 precise = true;
13981 }
13982 }
13983 }
13984 }
13985 if !precise {
13986 batch.table(&op.table);
13987 }
13988 }
13989 if let Err(error) = self.persist_outbox_insert(&commit) {
13990 self.rollback_observation("syncular_mutation");
13991 return Err(error);
13992 }
13993 let was_dirty = self.overlay_dirty.snapshot();
13994 if let Err(error) = self.rebuild_overlay_if_dirty() {
13999 self.rollback_observation("syncular_mutation");
14000 self.overlay_dirty.restore(was_dirty.clone());
14001 return Err(error);
14002 }
14003 if let Some(error) = commit
14004 .ops
14005 .iter()
14006 .find_map(|op| self.apply_outbox_op(op).err())
14007 {
14008 self.rollback_observation("syncular_mutation");
14009 self.overlay_dirty.restore(was_dirty.clone());
14010 return Err(error);
14011 }
14012 let id = commit.client_commit_id.clone();
14013 self.outbox.push(commit);
14014 batch.status = true;
14015 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
14016 self.rollback_observation("syncular_mutation");
14017 self.outbox.pop();
14018 self.overlay_dirty.restore(was_dirty.clone());
14019 return Err(error);
14020 }
14021 Ok(id)
14022 }
14023
14024 pub fn patch(
14030 &mut self,
14031 table: &str,
14032 row_id: &str,
14033 partial: Map<String, Value>,
14034 base_version: Option<i64>,
14035 ) -> Result<String, ClientError> {
14036 let prior = self.storage_failure.borrow_mut().take();
14037 let outcome = self.perform_patch(table, row_id, partial, base_version);
14038 self.finish_authoring(prior, outcome)
14039 }
14040
14041 fn perform_patch(
14044 &mut self,
14045 table: &str,
14046 row_id: &str,
14047 partial: Map<String, Value>,
14048 base_version: Option<i64>,
14049 ) -> Result<String, String> {
14050 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
14051 self.record_outbox_commit(vec![operation])
14052 }
14053
14054 fn prepare_patch(
14055 &self,
14056 table: &str,
14057 row_id: &str,
14058 partial: Map<String, Value>,
14059 base_version: Option<i64>,
14060 ) -> Result<OutboxOp, String> {
14061 let schema_table = self
14062 .schema
14063 .table(table)
14064 .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
14065 let partial = normalize_values_casing(schema_table, partial)?;
14066 if let Some(pk) = partial.get(&schema_table.primary_key) {
14067 if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
14068 return Err(format!(
14069 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
14070 ));
14071 }
14072 }
14073 let exists: bool = self
14076 .conn
14077 .query_row(
14078 &format!(
14079 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
14080 visible_table(table),
14081 row_id_predicate(schema_table)
14082 ),
14083 rusqlite::params![row_id],
14084 |row| row.get(0),
14085 )
14086 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14087 if !exists {
14088 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
14089 }
14090 for scope in &schema_table.scope_variables {
14100 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
14101 continue;
14102 }
14103 let Some(column) = schema_table
14104 .columns
14105 .iter()
14106 .find(|column| column.name == scope.column)
14107 else {
14108 continue;
14109 };
14110 let stored = self
14111 .conn
14112 .query_row(
14113 &format!(
14114 "SELECT {} FROM {} WHERE {}",
14115 quote_ident(&scope.column),
14116 visible_table(&schema_table.name),
14117 row_id_predicate(schema_table)
14118 ),
14119 rusqlite::params![row_id],
14120 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
14121 )
14122 .optional()
14123 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14124 let incoming = json_to_column_value(column, partial.get(&scope.column))
14129 .ok()
14130 .map(|value| column_value_to_json(&value));
14131 let equal = stored.as_ref().is_some_and(|stored| {
14135 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
14136 });
14137 if !equal {
14138 return Err(format!(
14139 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
14140 scope.column
14141 ));
14142 }
14143 }
14144 let mut values = partial;
14145 for scope in &schema_table.scope_variables {
14148 values.remove(&scope.column);
14149 }
14150 let pk_value = match schema_table.columns[schema_table.pk_index].ty {
14151 ColumnType::Integer => match row_id.parse::<i64>() {
14153 Ok(v)
14154 if v.to_string() == row_id
14155 && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
14156 .contains(&v) =>
14157 {
14158 Value::from(v)
14159 }
14160 _ => {
14161 return Err(format!(
14162 "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
14163 ))
14164 }
14165 },
14166 ColumnType::Boolean => match row_id {
14167 "true" => Value::Bool(true),
14168 "false" => Value::Bool(false),
14169 _ => {
14170 return Err(format!(
14171 "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
14172 ))
14173 }
14174 },
14175 _ => Value::from(row_id.to_owned()),
14176 };
14177 values.insert(schema_table.primary_key.clone(), pk_value);
14178 self.validate_author_encode(schema_table, row_id, &values)?;
14184 Ok(OutboxOp {
14185 upsert: true,
14186 table: table.to_owned(),
14187 row_id: row_id.to_owned(),
14188 base_version,
14189 values: Some(values),
14190 })
14191 }
14192
14193 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
14198 let selector = self.encryption.key_id_columns.get(&table.name)?;
14199 let index = table
14200 .columns
14201 .iter()
14202 .position(|column| &column.name == selector)?;
14203 let sql = format!(
14204 "SELECT {} FROM {} WHERE {}",
14205 quote_ident(selector),
14206 visible_table(&table.name),
14207 row_id_predicate(table)
14208 );
14209 let key: Option<String> = self
14210 .conn
14211 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
14212 .ok()?;
14213 let key = key.filter(|key| !key.is_empty())?;
14214 let mut fallback: Row = vec![None; table.columns.len()];
14215 fallback[index] = Some(ColumnValue::String(key));
14216 Some(fallback)
14217 }
14218
14219 fn validate_author_encode(
14224 &self,
14225 table: &TableSchema,
14226 row_id: &str,
14227 values: &Map<String, Value>,
14228 ) -> Result<(), String> {
14229 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
14230 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
14231 result => result.map(|_| ()),
14232 }
14233 }
14234
14235 fn drop_unencodable_outbox(
14249 &mut self,
14250 with_encryption: bool,
14251 batch: Option<&mut ChangeAccumulator>,
14252 ) -> Result<bool, String> {
14253 let failures = self
14254 .outbox
14255 .iter()
14256 .filter_map(|commit| {
14257 commit
14258 .ops
14259 .iter()
14260 .find_map(|op| {
14261 let values = op.values.as_ref().filter(|_| op.upsert)?;
14262 let table = self.schema.table(&op.table)?;
14263 let fallback = self.stored_key_fallback(table, &op.row_id);
14264 match encode_sparse_row_json(
14265 table,
14266 &op.row_id,
14267 values,
14268 &self.encryption,
14269 fallback.as_ref(),
14270 ) {
14271 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
14272 with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
14273 }
14274 Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
14279 LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
14280 OUTBOX_INCOMPATIBLE_CODE,
14281 )),
14282 _ => None,
14283 }
14284 })
14285 .map(|(error, code)| (commit.clone(), error, code))
14286 })
14287 .collect::<Vec<_>>();
14288 if failures.is_empty() {
14289 return Ok(false);
14290 }
14291 let prior_outbox = self.outbox.clone();
14297 let prior_rejections = self.rejections.clone();
14298 let prior_failed_commits = self.failed_commits.clone();
14302 let prior_changes = self.change_queue.len();
14303 let prior_intents = self.sync_intent_queue.len();
14304 let prior_last_change = self.last_change.clone();
14305 let prior_overlay_dirty = self.overlay_dirty.snapshot();
14306 let owns_observation = batch.is_none();
14307 let owns_transaction = self.conn.is_autocommit();
14308 let mut domains = ChangeAccumulator::default();
14309 if owns_observation {
14310 self.begin_observation("syncular_value_recovery")?;
14311 }
14312 let dropped = (|| -> Result<(), String> {
14313 for (commit, message, code) in failures {
14314 if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
14315 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
14316 }
14317 let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
14318 let rejection = RejectionRecord {
14319 client_commit_id: commit.client_commit_id.clone(),
14320 op_index: 0,
14321 code: code.to_owned(),
14322 message,
14323 retryable: false,
14324 details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
14325 field_paths: None,
14326 reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
14327 required_action: None,
14328 references: None,
14329 }),
14330 operation: representable
14331 .then(|| commit.ops.first())
14332 .flatten()
14333 .map(CommitOperation::from),
14334 };
14335 self.persist_commit_outcome(
14336 &commit.client_commit_id,
14337 CommitOutcomeStatus::Rejected,
14338 &[CommitOperationOutcome::Error {
14339 rejection: rejection.clone(),
14340 }],
14341 representable.then_some(commit.ops.as_slice()),
14342 )?;
14343 self.delete_outbox_persisted(&commit.client_commit_id)?;
14344 self.outbox
14345 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
14346 self.rejections.push(rejection);
14347 for operation in &commit.ops {
14348 domains.table(operation.table.as_str());
14349 }
14350 }
14351 domains.status = true;
14352 domains.rejections = true;
14353 domains.outcomes = true;
14354 self.prune_commit_outcomes()?;
14355 self.overlay_dirty.set(true);
14357 self.rebuild_overlay()?;
14358 Ok(())
14359 })();
14360 let result = match batch {
14361 Some(batch) => {
14362 if dropped.is_ok() {
14363 for table in domains.tables.keys() {
14364 batch.table(table.as_str());
14365 }
14366 batch.status |= domains.status;
14367 batch.rejections |= domains.rejections;
14368 batch.outcomes |= domains.outcomes;
14369 }
14370 dropped
14371 }
14372 None => {
14373 dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
14374 }
14375 };
14376 if let Err(error) = result {
14377 if owns_observation {
14378 if owns_transaction {
14379 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
14380 let mut failure = self.storage_failure.borrow_mut();
14381 let failure =
14382 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
14383 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
14384 }
14385 } else {
14386 self.rollback_observation("syncular_value_recovery");
14387 }
14388 }
14389 self.outbox = prior_outbox;
14390 self.rejections = prior_rejections;
14391 self.failed_commits = prior_failed_commits;
14392 self.change_queue.truncate(prior_changes);
14393 self.sync_intent_queue.truncate(prior_intents);
14394 self.last_change = prior_last_change;
14395 self.overlay_dirty.restore(prior_overlay_dirty);
14396 return Err(error);
14397 }
14398 Ok(true)
14399 }
14400
14401 pub fn pending_commit_ids(&self) -> Vec<String> {
14402 self.outbox
14403 .iter()
14404 .map(|c| c.client_commit_id.clone())
14405 .collect()
14406 }
14407
14408 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
14412 let mut payloads = Vec::new();
14413 for commit in &self.outbox {
14414 for op in &commit.ops {
14415 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
14416 continue;
14417 };
14418 let Some(table) = self.schema.table(&op.table) else {
14419 continue;
14420 };
14421 let fallback = self.stored_key_fallback(table, &op.row_id);
14422 if let Ok(payload) = encode_sparse_row_json(
14423 table,
14424 &op.row_id,
14425 values,
14426 &self.encryption,
14427 fallback.as_ref(),
14428 ) {
14429 payloads.push(payload);
14430 }
14431 }
14432 }
14433 payloads
14434 }
14435
14436 pub fn conflicts(&self) -> &[ConflictRecord] {
14437 &self.conflicts
14438 }
14439
14440 pub fn rejections(&self) -> &[RejectionRecord] {
14441 &self.rejections
14442 }
14443
14444 pub fn resolve_commit_outcome(
14445 &mut self,
14446 input: ResolveCommitOutcomeInput,
14447 ) -> Result<CommitOutcome, String> {
14448 let current = self
14449 .commit_outcome(&input.client_commit_id)?
14450 .ok_or_else(|| {
14451 format!(
14452 "sync.outcome_not_found: no durable outcome exists for {:?}",
14453 input.client_commit_id
14454 )
14455 })?;
14456 if current.resolution != CommitOutcomeResolution::Active {
14457 return Ok(current);
14458 }
14459 if input.resolution == CommitOutcomeResolution::Active {
14460 return Err("sync.invalid_request: resolution must leave active state".to_owned());
14461 }
14462 match input.resolution {
14463 CommitOutcomeResolution::Superseded => {
14464 let replacement = input
14465 .replacement_client_commit_id
14466 .as_deref()
14467 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
14468 .ok_or_else(|| {
14469 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
14470 .to_owned()
14471 })?;
14472 let _ = replacement;
14473 }
14474 _ if input.replacement_client_commit_id.is_some() => {
14475 return Err(
14476 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
14477 .to_owned(),
14478 );
14479 }
14480 _ => {}
14481 }
14482 let allowed = match current.status {
14483 CommitOutcomeStatus::Conflict => matches!(
14484 input.resolution,
14485 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
14486 ),
14487 CommitOutcomeStatus::Rejected => {
14488 matches!(
14489 input.resolution,
14490 CommitOutcomeResolution::Superseded
14491 | CommitOutcomeResolution::ResolvedKeepServer
14492 )
14493 }
14494 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
14495 input.resolution == CommitOutcomeResolution::Dismissed
14496 }
14497 };
14498 if !allowed {
14499 return Err(format!(
14500 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
14501 input.resolution, current.status
14502 ));
14503 }
14504
14505 self.begin_observation("syncular_outcome_resolution")?;
14506 let previous_failed = self.failed_commits.clone();
14507 let previous_conflicts = self.conflicts.clone();
14508 let previous_rejections = self.rejections.clone();
14509 let result = (|| {
14510 self.conn
14511 .execute(
14512 "UPDATE _syncular_commit_outcomes
14513 SET resolution = ?1, resolved_at_ms = ?2,
14514 replacement_client_commit_id = ?3
14515 WHERE client_commit_id = ?4 AND resolution = 'active'",
14516 rusqlite::params![
14517 Self::outcome_resolution_name(input.resolution),
14518 self.clock_now_ms(),
14519 input.replacement_client_commit_id,
14520 input.client_commit_id
14521 ],
14522 )
14523 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14524 let resolved = self
14525 .commit_outcome(¤t.client_commit_id)?
14526 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
14527 self.conn
14528 .execute(
14529 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
14530 rusqlite::params![input.client_commit_id],
14531 )
14532 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14533 for (commit, _) in &self.failed_commits {
14534 if commit.client_commit_id == input.client_commit_id {
14535 for operation in &commit.ops {
14536 self.overlay_dirty.table(&operation.table);
14537 }
14538 }
14539 }
14540 self.failed_commits
14541 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
14542 self.prune_commit_outcomes()?;
14543 Ok(resolved)
14544 })();
14545 let resolved = match result {
14546 Ok(outcome) => outcome,
14547 Err(error) => {
14548 self.rollback_observation("syncular_outcome_resolution");
14549 self.failed_commits = previous_failed;
14550 self.conflicts = previous_conflicts;
14551 self.rejections = previous_rejections;
14552 return Err(error);
14553 }
14554 };
14555 self.conflicts
14556 .retain(|record| record.client_commit_id != current.client_commit_id);
14557 self.rejections
14558 .retain(|record| record.client_commit_id != current.client_commit_id);
14559 let mut batch = ChangeAccumulator {
14560 conflicts: current.status == CommitOutcomeStatus::Conflict,
14561 rejections: current.status == CommitOutcomeStatus::Rejected,
14562 outcomes: true,
14563 ..ChangeAccumulator::default()
14564 };
14565 if let Some(operations) = ¤t.operations {
14566 for operation in operations {
14567 batch.table(&operation.table);
14568 }
14569 }
14570 if let Err(error) = self
14571 .rebuild_overlay_if_dirty()
14572 .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
14573 {
14574 self.rollback_observation("syncular_outcome_resolution");
14575 self.failed_commits = previous_failed;
14576 self.conflicts = previous_conflicts;
14577 self.rejections = previous_rejections;
14578 return Err(error);
14579 }
14580 Ok(resolved)
14581 }
14582
14583 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
14584 self.schema_floor.as_ref()
14585 }
14586
14587 pub fn lease_state(&self) -> Option<&LeaseState> {
14589 self.lease_state.as_ref()
14590 }
14591
14592 fn record_lease_error(&mut self, code: &str) {
14595 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
14596 return;
14597 }
14598 let mut next = self.lease_state.clone().unwrap_or_default();
14599 next.error_code = Some(code.to_owned());
14600 self.set_lease_state(Some(next));
14601 }
14602
14603 fn set_lease_state(&mut self, next: Option<LeaseState>) {
14604 if self.lease_state == next {
14605 return;
14606 }
14607 if self.begin_observation("syncular_lease").is_err() {
14608 return;
14609 }
14610 self.lease_state = next;
14611 if let Some(lease) = &self.lease_state {
14612 if let Ok(json) = serde_json::to_string(lease) {
14613 self.set_meta(LEASE_STATE_KEY, &json);
14614 }
14615 } else {
14616 self.delete_meta(LEASE_STATE_KEY);
14617 }
14618 let batch = ChangeAccumulator {
14619 status: true,
14620 ..ChangeAccumulator::default()
14621 };
14622 if self.finish_observation("syncular_lease", batch).is_err() {
14623 self.rollback_observation("syncular_lease");
14624 }
14625 }
14626
14627 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
14628 if self.schema_floor == next {
14629 return;
14630 }
14631 if self.begin_observation("syncular_schema_floor").is_err() {
14632 return;
14633 }
14634 self.schema_floor = next;
14635 self.stopped = self.schema_floor.is_some();
14636 if let Some(floor) = &self.schema_floor {
14637 if let Ok(json) = serde_json::to_string(floor) {
14638 self.set_meta(SCHEMA_FLOOR_KEY, &json);
14639 }
14640 } else {
14641 self.delete_meta(SCHEMA_FLOOR_KEY);
14642 }
14643 let batch = ChangeAccumulator {
14644 status: true,
14645 ..ChangeAccumulator::default()
14646 };
14647 if self
14648 .finish_observation("syncular_schema_floor", batch)
14649 .is_err()
14650 {
14651 self.rollback_observation("syncular_schema_floor");
14652 }
14653 }
14654
14655 fn set_upgrading(&mut self, value: bool) {
14656 if self.upgrading == value {
14657 return;
14658 }
14659 if self.begin_observation("syncular_upgrading").is_err() {
14660 return;
14661 }
14662 self.upgrading = value;
14663 let batch = ChangeAccumulator {
14664 status: true,
14665 ..ChangeAccumulator::default()
14666 };
14667 if self
14668 .finish_observation("syncular_upgrading", batch)
14669 .is_err()
14670 {
14671 self.rollback_observation("syncular_upgrading");
14672 }
14673 }
14674
14675 pub fn sync_needed(&self) -> bool {
14676 self.sync_needed
14677 }
14678
14679 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
14680 let sub = self.subs.iter().find(|s| s.id == id)?;
14681 Some(SubscriptionStateView {
14682 id: sub.id.clone(),
14683 table: sub.table.clone(),
14684 status: sub.state.name().to_owned(),
14685 cursor: sub.cursor,
14686 has_resume_token: sub.bootstrap_state.is_some(),
14687 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
14688 reason_code: sub.reason_code.clone(),
14689 })
14690 }
14691
14692 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
14693 let schema_table = self
14694 .schema
14695 .table(table)
14696 .ok_or_else(|| format!("unknown table {table:?}"))?;
14697 let sql = format!(
14698 "SELECT * FROM {} ORDER BY {} ASC",
14699 visible_table(table),
14700 quote_ident(&schema_table.primary_key)
14701 );
14702 let mut stmt = self
14703 .conn
14704 .prepare(&sql)
14705 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14706 let mut rows = stmt
14707 .query([])
14708 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14709 let mut out = Vec::new();
14710 while let Some(row) = rows
14711 .next()
14712 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14713 {
14714 let mut values = Map::new();
14715 for (i, column) in schema_table.columns.iter().enumerate() {
14716 let value = row
14717 .get_ref(i)
14718 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14719 values.insert(column.name.clone(), sql_ref_to_json(column, value));
14720 }
14721 let version: i64 = row
14722 .get(schema_table.columns.len())
14723 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14724 let row_id = match values.get(&schema_table.primary_key) {
14725 Some(Value::String(s)) => s.clone(),
14726 Some(Value::Number(n)) => n.to_string(),
14727 Some(Value::Bool(b)) => b.to_string(),
14728 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
14729 };
14730 out.push(RowState {
14731 row_id,
14732 version,
14733 values,
14734 });
14735 }
14736 Ok(out)
14737 }
14738
14739 #[cfg(feature = "crdt-yjs")]
14755 fn crdt_column_bytes(
14756 &self,
14757 table: &str,
14758 row_id: &str,
14759 column: &str,
14760 ) -> Result<Option<Vec<u8>>, String> {
14761 let schema_table = self
14762 .schema
14763 .table(table)
14764 .ok_or_else(|| format!("unknown table {table:?}"))?;
14765 let col = schema_table
14766 .columns
14767 .iter()
14768 .find(|c| c.name == column)
14769 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
14770 if col.ty != ColumnType::Crdt {
14771 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
14772 }
14773 let sql = format!(
14774 "SELECT {} FROM {} WHERE {}",
14775 quote_ident(column),
14776 visible_table(table),
14777 row_id_predicate(schema_table)
14778 );
14779 let bytes: Option<Vec<u8>> = self
14780 .conn
14781 .prepare_cached(&sql)
14782 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14783 .query_row(rusqlite::params![row_id], |row| {
14784 row.get::<_, Option<Vec<u8>>>(0)
14785 })
14786 .map_err(|e| match e {
14787 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
14788 other => Self::sqlite_failure(&self.storage_failure, other),
14789 })?;
14790 Ok(bytes)
14791 }
14792
14793 #[cfg(feature = "crdt-yjs")]
14797 pub fn crdt_text(
14798 &self,
14799 table: &str,
14800 row_id: &str,
14801 column: &str,
14802 name: &str,
14803 ) -> Result<String, String> {
14804 let bytes = self
14805 .crdt_column_bytes(table, row_id, column)?
14806 .unwrap_or_default();
14807 crate::crdt::text(&bytes, name)
14808 }
14809
14810 #[cfg(feature = "crdt-yjs")]
14814 pub fn crdt_insert_text(
14815 &mut self,
14816 table: &str,
14817 row_id: &str,
14818 column: &str,
14819 name: &str,
14820 index: u32,
14821 value: &str,
14822 ) -> Result<String, ClientError> {
14823 let prior = self.storage_failure.borrow_mut().take();
14824 let outcome = (|| -> Result<String, String> {
14825 let current = self
14826 .crdt_column_bytes(table, row_id, column)?
14827 .unwrap_or_default();
14828 let update = crate::crdt::insert_text(¤t, name, index, value)?;
14829 self.perform_crdt_push_update(table, row_id, column, &update)
14830 })();
14831 self.finish_authoring(prior, outcome)
14832 }
14833
14834 #[cfg(feature = "crdt-yjs")]
14838 pub fn crdt_delete_text(
14839 &mut self,
14840 table: &str,
14841 row_id: &str,
14842 column: &str,
14843 name: &str,
14844 index: u32,
14845 len: u32,
14846 ) -> Result<String, ClientError> {
14847 let prior = self.storage_failure.borrow_mut().take();
14848 let outcome = (|| -> Result<String, String> {
14849 let current = self
14850 .crdt_column_bytes(table, row_id, column)?
14851 .unwrap_or_default();
14852 let update = crate::crdt::delete_text(¤t, name, index, len)?;
14853 self.perform_crdt_push_update(table, row_id, column, &update)
14854 })();
14855 self.finish_authoring(prior, outcome)
14856 }
14857
14858 #[cfg(feature = "crdt-yjs")]
14863 pub fn crdt_apply_update(
14864 &mut self,
14865 table: &str,
14866 row_id: &str,
14867 column: &str,
14868 update: &[u8],
14869 ) -> Result<String, ClientError> {
14870 let prior = self.storage_failure.borrow_mut().take();
14871 let outcome = (|| -> Result<String, String> {
14872 let current = self
14873 .crdt_column_bytes(table, row_id, column)?
14874 .unwrap_or_default();
14875 let next = crate::crdt::apply_update(¤t, update)?;
14876 self.perform_crdt_push_update(table, row_id, column, &next)
14877 })();
14878 self.finish_authoring(prior, outcome)
14879 }
14880
14881 #[cfg(feature = "crdt-yjs")]
14888 fn perform_crdt_push_update(
14889 &mut self,
14890 table: &str,
14891 row_id: &str,
14892 column: &str,
14893 crdt_bytes: &[u8],
14894 ) -> Result<String, String> {
14895 let mut bytes_obj = Map::new();
14896 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
14897 let mut partial = Map::new();
14898 partial.insert(column.to_owned(), Value::Object(bytes_obj));
14899 self.perform_patch(table, row_id, partial, None)
14900 }
14901
14902 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
14917 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
14918 }
14919
14920 #[cfg(feature = "bench-internals")]
14922 #[doc(hidden)]
14923 pub fn benchmark_connection(&mut self) -> &mut Connection {
14924 &mut self.conn
14925 }
14926
14927 #[cfg(feature = "bench-internals")]
14929 #[doc(hidden)]
14930 pub fn benchmark_phases(
14931 &self,
14932 enabled: Option<bool>,
14933 reset: bool,
14934 ) -> Result<Option<Value>, String> {
14935 self.benchmark_phases.configure(enabled, reset)?;
14936 Ok(self.benchmark_phases.snapshot())
14937 }
14938
14939 pub fn authority_snapshot(&self) -> Result<Value, String> {
14941 validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
14942 if self.limits.authority_reads.is_empty() {
14943 return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
14944 }
14945 self.conn
14946 .execute_batch("SAVEPOINT authority_snapshot")
14947 .map_err(|e| e.to_string())?;
14948 let result = (|| {
14949 let revision = self
14950 .get_meta(LOCAL_REVISION_KEY)
14951 .ok_or("sync.local_corrupt: authority revision is missing")?;
14952 let parsed_revision = revision
14953 .parse::<u64>()
14954 .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
14955 if parsed_revision.to_string() != revision {
14956 return Err("sync.local_corrupt: authority revision is invalid".into());
14957 }
14958 let mut tables = Vec::new();
14959 for read in &self.limits.authority_reads {
14960 let table = self
14961 .schema
14962 .table(&read.table)
14963 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
14964 let scalar = |column: &str, value: &Value| -> Result<String, String> {
14965 let ty = table
14966 .columns
14967 .iter()
14968 .find(|c| c.name == column)
14969 .expect("validated authority column")
14970 .ty;
14971 if ty == ColumnType::Boolean {
14972 if let Some(value) = value.as_bool() {
14973 return Ok(value.to_string());
14974 }
14975 return match value.as_i64() {
14976 Some(0) => Ok("false".into()),
14977 Some(1) => Ok("true".into()),
14978 _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
14979 };
14980 }
14981 match value {
14982 Value::String(s) => Ok(s.clone()),
14983 Value::Number(n) => Ok(n.to_string()),
14984 Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
14985 .get("$bigint")
14986 .and_then(Value::as_str)
14987 .map(str::to_owned)
14988 .ok_or("sync.local_corrupt: authority integer is invalid".into()),
14989 _ => {
14990 Err("sync.local_corrupt: authority scalar is missing or invalid".into())
14991 }
14992 }
14993 };
14994 let matches = |row: &QueryRow| -> Result<bool, String> {
14995 for (variable, values) in &read.scopes {
14996 let column = table
14997 .scope_column(variable)
14998 .expect("validated authority scope");
14999 let value = row
15000 .get(column)
15001 .ok_or("sync.local_corrupt: authority scope column is missing")?;
15002 if value.is_null() || !values.contains(&scalar(column, value)?) {
15003 return Ok(false);
15004 }
15005 }
15006 Ok(true)
15007 };
15008 let selected = read
15009 .columns
15010 .iter()
15011 .map(|c| quote_ident(c))
15012 .collect::<Vec<_>>()
15013 .join(",");
15014 let mut intent = BTreeSet::new();
15015 let mut relevant = BTreeSet::new();
15016 for row in query_connection(&self.conn,
15017 "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=?",
15018 &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
15019 let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
15020 intent.insert(id.clone());
15021 if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
15022 let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
15023 if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
15024 }
15025 }
15026 let mut rows = BTreeMap::new();
15027 for full in [base_table(&read.table), visible_table(&read.table)] {
15028 for mut values in query_connection(
15029 &self.conn,
15030 &format!(
15031 "SELECT {selected},_syncular_version AS authority_version FROM {full}"
15032 ),
15033 &[],
15034 )
15035 .map_err(|e| e.to_string())?
15036 {
15037 if !matches(&values)? {
15038 continue;
15039 }
15040 let id = scalar(
15041 &table.primary_key,
15042 values
15043 .get(&table.primary_key)
15044 .expect("selected primary key"),
15045 )?;
15046 relevant.insert(id.clone());
15047 if full == base_table(&read.table) {
15048 let version = values
15049 .remove("authority_version")
15050 .expect("selected version");
15051 let version = version
15052 .as_i64()
15053 .ok_or("sync.local_corrupt: authority server version is invalid")?;
15054 if version >= 0 {
15055 rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
15056 }
15057 }
15058 }
15059 }
15060 intent.retain(|id| relevant.contains(id));
15061 let rows = rows.into_values().collect::<Vec<_>>();
15062 let mut persisted = Vec::new();
15063 for row in query_connection(
15064 &self.conn,
15065 "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
15066 &[Value::String(read.table.clone())],
15067 )
15068 .map_err(|e| e.message)?
15069 {
15070 let state: Value = serde_json::from_str(
15071 row.get("state_json")
15072 .and_then(Value::as_str)
15073 .ok_or("sync.local_corrupt: authority subscription state is missing")?,
15074 )
15075 .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
15076 let effective = state.get("effectiveScopes").and_then(Value::as_object);
15077 let status = state
15078 .get("status")
15079 .and_then(Value::as_str)
15080 .ok_or("sync.local_corrupt: authority subscription status is missing")?;
15081 let cursor = state
15082 .get("cursor")
15083 .and_then(Value::as_i64)
15084 .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
15085 let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
15086 && status == "active"
15087 && cursor >= 0
15088 && state.get("bootstrapState").is_none_or(Value::is_null)
15089 && state.get("params").is_none_or(Value::is_null);
15090 let exposed = effective.map(|s| {
15091 read.scopes
15092 .keys()
15093 .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
15094 .collect::<Map<_, _>>()
15095 });
15096 let requested = read
15097 .scopes
15098 .keys()
15099 .map(|v| {
15100 (
15101 v.clone(),
15102 state
15103 .get("requested")
15104 .and_then(|s| s.get(v))
15105 .cloned()
15106 .unwrap_or(json!([])),
15107 )
15108 })
15109 .collect::<Map<_, _>>();
15110 persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
15111 }
15112 fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
15113 match selectors.split_first() {
15114 None => !candidates.is_empty(),
15115 Some(((variable, values), rest)) => values.iter().all(|value| {
15116 covered(
15117 rest,
15118 &candidates
15119 .iter()
15120 .copied()
15121 .filter(|p| {
15122 p["effectiveScopes"][variable.as_str()]
15123 .as_array()
15124 .is_some_and(|held| {
15125 held.contains(&Value::String(value.clone()))
15126 || held.contains(&Value::String("*".into()))
15127 })
15128 })
15129 .collect::<Vec<_>>(),
15130 )
15131 }),
15132 }
15133 }
15134 let coverage = if covered(
15135 &read.scopes.iter().collect::<Vec<_>>(),
15136 &persisted
15137 .iter()
15138 .filter(|p| p["complete"] == true)
15139 .collect::<Vec<_>>(),
15140 ) {
15141 "complete"
15142 } else if persisted.iter().any(|p| {
15143 p["status"] == "active"
15144 && read.scopes.iter().all(|(v, values)| {
15145 values.iter().any(|value| {
15146 p["requestedScopes"][v].as_array().is_some_and(|held| {
15147 held.contains(&Value::String(value.clone()))
15148 || held.contains(&Value::String("*".into()))
15149 })
15150 })
15151 })
15152 }) {
15153 "pending"
15154 } else {
15155 "missing"
15156 };
15157 tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
15158 }
15159 Ok(
15160 json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
15161 )
15162 })();
15163 match result {
15164 Ok(snapshot) => {
15165 self.conn
15166 .execute_batch("RELEASE authority_snapshot")
15167 .map_err(|e| e.to_string())?;
15168 Ok(snapshot)
15169 }
15170 Err(error) => {
15171 self.conn
15172 .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
15173 .map_err(|e| format!("{error}; rollback failed: {e}"))?;
15174 Err(error)
15175 }
15176 }
15177 }
15178
15179 pub fn query_snapshot(
15183 &mut self,
15184 sql: &str,
15185 params: &[QueryValue],
15186 coverage: &[WindowCoverage],
15187 owner: Option<QueryOwner<'_>>,
15188 ) -> Result<QuerySnapshot, String> {
15189 let result = snapshot_connection(&self.conn, sql, params, coverage);
15190 if let Some(owner) = owner {
15191 self.record_query_read(owner, result.as_ref().err());
15192 }
15193 result.map_err(|failure| failure.to_string())
15194 }
15195
15196 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
15200 let position = self
15201 .query_failures
15202 .iter()
15203 .position(|entry| entry.id == owner.id);
15204 let Some(failure) = failure else {
15205 if let Some(index) = position {
15206 self.query_failures.remove(index);
15207 }
15208 return;
15209 };
15210 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
15211 .into_iter()
15212 .map(str::to_owned)
15213 .collect();
15214 let code = failure.code.unwrap_or("client.query_failed").to_owned();
15215 if position.is_some_and(|index| {
15216 let entry = &self.query_failures[index];
15217 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
15218 }) {
15219 return;
15220 }
15221 if let Some(index) = position {
15222 self.query_failures.remove(index);
15223 }
15224 self.query_failures.push(DiagnosticQueryFailure {
15225 id: owner.id.to_owned(),
15226 tables,
15227 code,
15228 sqlite_code: failure.sqlite_code,
15229 at_ms: self.clock_now_ms(),
15230 });
15231 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
15232 self.query_failures.remove(0);
15233 }
15234 }
15235
15236 fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
15239 #[cfg(feature = "bench-internals")]
15240 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
15241 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
15242 let max_commits = self.limits.max_push_commits_per_request;
15243 let max_ops = self
15244 .limits
15245 .max_push_operations_per_request
15246 .unwrap_or(PUSH_OPS_PER_REQUEST);
15247 let byte_cap = self.limits.max_push_request_bytes;
15248 let accept = self.limits.accept.unwrap_or(if url_capable {
15251 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
15252 } else {
15253 DEFAULT_ACCEPT
15254 });
15255 let header = Frame::ReqHeader {
15256 client_id: self.client_id.clone(),
15257 schema_version: self.schema.version,
15258 log_epoch: log_epoch.clone(),
15259 };
15260 let pull = Frame::PullHeader {
15261 limit_commits: self.limits.limit_commits.unwrap_or(0),
15262 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
15263 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
15264 accept,
15265 };
15266 let mut fresh = Vec::new();
15267 let mut sub_frames = Vec::new();
15268 for sub in &self.subs {
15269 if sub.state != SubState::Active {
15270 continue;
15271 }
15272 let mut scopes = sub.requested.clone();
15273 sort_scope_map(&mut scopes);
15274 sub_frames.push(Frame::Subscription {
15275 id: sub.id.clone(),
15276 table: sub.table.clone(),
15277 scopes,
15278 params: sub.params.clone().map(RawJson),
15279 cursor: sub.cursor,
15280 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
15281 });
15282 fresh.push((
15283 sub.id.clone(),
15284 sub.cursor < 0 && sub.bootstrap_state.is_none(),
15285 ));
15286 }
15287 let fixed_bytes = match byte_cap {
15290 Some(_) => {
15291 let mut frames = vec![header.clone(), pull.clone()];
15292 frames.extend(sub_frames.iter().cloned());
15293 encode_message(&Message {
15294 wire_version: WIRE_VERSION,
15295 msg_kind: MsgKind::Request,
15296 frames,
15297 })
15298 .len()
15299 }
15300 None => 0,
15301 };
15302 if let Some(cap) = byte_cap {
15303 if fixed_bytes > cap {
15304 return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
15305 }
15306 }
15307 let base_bytes = match byte_cap {
15308 Some(_) => encode_message(&Message {
15309 wire_version: WIRE_VERSION,
15310 msg_kind: MsgKind::Request,
15311 frames: vec![header.clone(), pull.clone()],
15312 })
15313 .len(),
15314 None => 0,
15315 };
15316 let total_outbox = if log_epoch.is_some() {
15317 self.outbox.len()
15318 } else {
15319 0
15320 };
15321 let mut push_frames: Vec<Frame> = Vec::new();
15322 let mut pushed_ids = Vec::new();
15323 let mut ops_in_request = 0usize;
15324 let mut bytes_in_request = 0usize;
15325 let mut deferred_commits = 0usize;
15326 #[cfg(feature = "bench-internals")]
15327 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
15328 for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
15329 if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
15333 deferred_commits = total_outbox - index;
15334 break;
15335 }
15336 if ops_in_request + commit.ops.len() > max_ops {
15337 if push_frames.is_empty() {
15338 return Err(Self::push_capacity_outcome(
15339 "operations",
15340 max_ops,
15341 commit.ops.len(),
15342 Some(commit.client_commit_id.as_str()),
15343 ));
15344 }
15345 deferred_commits = total_outbox - index;
15346 break;
15347 }
15348 let operations = commit
15349 .ops
15350 .iter()
15351 .map(|op| {
15352 let payload = op.values.as_ref().and_then(|values| {
15353 let table = self.schema.table(&op.table)?;
15354 let fallback = self.stored_key_fallback(table, &op.row_id);
15363 encode_sparse_row_json(
15364 table,
15365 &op.row_id,
15366 values,
15367 &self.encryption,
15368 fallback.as_ref(),
15369 )
15370 .ok()
15371 });
15372 ssp2::model::Operation {
15373 table: op.table.clone(),
15374 row_id: op.row_id.clone(),
15375 op: if op.upsert { Op::Upsert } else { Op::Delete },
15376 base_version: op.base_version,
15377 payload,
15378 }
15379 })
15380 .collect();
15381 let frame = Frame::PushCommit {
15382 client_commit_id: commit.client_commit_id.clone(),
15383 operations,
15384 };
15385 let frame_bytes = match byte_cap {
15386 Some(_) => {
15387 encode_message(&Message {
15388 wire_version: WIRE_VERSION,
15389 msg_kind: MsgKind::Request,
15390 frames: vec![header.clone(), frame.clone(), pull.clone()],
15391 })
15392 .len()
15393 - base_bytes
15394 }
15395 None => 0,
15396 };
15397 if let Some(cap) = byte_cap {
15401 if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
15402 deferred_commits = total_outbox - index;
15403 break;
15404 }
15405 }
15406 push_frames.push(frame);
15407 pushed_ids.push(commit.client_commit_id.clone());
15408 ops_in_request += commit.ops.len();
15409 bytes_in_request += frame_bytes;
15410 }
15411 #[cfg(feature = "bench-internals")]
15412 drop(outbox_phase);
15413 if let Some(cap) = byte_cap {
15414 if fixed_bytes + bytes_in_request > cap {
15415 let blocked = if push_frames.is_empty() {
15418 None
15419 } else {
15420 self.outbox.first().map(|c| c.client_commit_id.as_str())
15421 };
15422 return Err(Self::push_capacity_outcome(
15423 "bytes",
15424 cap,
15425 fixed_bytes + bytes_in_request,
15426 blocked,
15427 ));
15428 }
15429 }
15430 let mut frames = vec![header];
15431 frames.extend(push_frames);
15432 frames.push(pull);
15433 frames.extend(sub_frames);
15434 let message = Message {
15435 wire_version: WIRE_VERSION,
15436 msg_kind: MsgKind::Request,
15437 frames,
15438 };
15439 Ok((
15440 message,
15441 RequestMeta {
15442 pushed_ids,
15443 fresh,
15444 accept,
15445 deferred_commits,
15446 },
15447 ))
15448 }
15449
15450 fn push_capacity_outcome(
15452 kind: &str,
15453 limit: usize,
15454 size: usize,
15455 client_commit_id: Option<&str>,
15456 ) -> Box<SyncOutcome> {
15457 let mut details = Map::new();
15458 details.insert("kind".to_owned(), Value::from(kind));
15459 details.insert("limit".to_owned(), Value::from(limit as u64));
15460 details.insert("size".to_owned(), Value::from(size as u64));
15461 if let Some(id) = client_commit_id {
15462 details.insert("clientCommitId".to_owned(), Value::from(id));
15463 }
15464 Box::new(SyncOutcome::Failed {
15465 error_code: "client.push_request_too_large".into(),
15466 message: "the push request exceeds a configured capacity limit".into(),
15467 details: Some(Value::Object(details)),
15468 })
15469 }
15470
15471 pub fn progress(&self) -> ProgressObserver {
15475 self.progress.clone()
15476 }
15477
15478 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
15479 let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
15480 Ok(prepared) => prepared,
15481 Err(outcome) => return *outcome,
15482 };
15483 let mut next = prepared;
15488 loop {
15489 match self.apply_sync_round(next.exchange(transport)) {
15490 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
15491 crate::AppliedSyncRound::Complete {
15492 outcome, controls, ..
15493 } => {
15494 if self.transport_enabled {
15495 for text in controls {
15496 let _ = transport.realtime_send(&text);
15497 }
15498 } else {
15499 self.disconnect_realtime(transport);
15500 }
15501 return outcome;
15502 }
15503 }
15504 }
15505 }
15506
15507 fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
15508 if let SyncOutcome::Failed {
15509 error_code,
15510 message,
15511 details,
15512 } = &mut outcome
15513 {
15514 if let Some(failure) = self.storage_failure.borrow().as_ref() {
15515 let code = failure
15516 .code
15517 .expect("retained storage failures are classified");
15518 *error_code = code.to_owned();
15519 *message = match code {
15520 "client.storage_busy" => "local SQLite storage is busy",
15521 "client.storage_full" => "local SQLite storage is full",
15522 "client.storage_io" => "local SQLite storage I/O failed",
15523 "client.storage_corrupt" => "local SQLite storage is corrupt",
15524 _ => unreachable!("unrecognized classified storage failure"),
15525 }
15526 .to_owned();
15527 *details = failure.details();
15528 }
15529 }
15530 let retry_delay_ms = self.round_retry_delay_ms;
15531 self.progress.update(|p| match &outcome {
15532 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
15533 if report.failed.is_empty() =>
15534 {
15535 p.state = ProgressState::Complete
15536 }
15537 SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
15538 p.state = ProgressState::Failed;
15539 p.error_code = Some("sync.scope_revoked".into());
15540 }
15541 SyncOutcome::Failed { error_code, .. } => {
15542 p.state = ProgressState::Failed;
15543 p.error_code = Some(Self::diagnostic_code(error_code));
15544 p.retry_delay_ms = retry_delay_ms;
15545 }
15546 SyncOutcome::RealtimeUnavailable { .. } => {
15547 p.state = ProgressState::Failed;
15548 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
15549 p.retry_delay_ms = retry_delay_ms;
15550 }
15551 });
15552 let completed_at_ms = self.clock_now_ms();
15553 self.last_round = Some(match &outcome {
15554 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
15555 status: "succeeded".to_owned(),
15556 started_at_ms,
15557 completed_at_ms,
15558 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15559 counters: Some(DiagnosticRoundCounters {
15560 pushed: report.pushed,
15561 applied: report.applied.len(),
15562 rejected: report.rejected.len(),
15563 retryable: report.retryable.len(),
15564 conflicts: report.conflicts,
15565 commits_applied: report.commits_applied,
15566 segment_rows_applied: report.segment_rows_applied,
15567 bootstrapping: report.bootstrapping.len(),
15568 resets: report.resets.len(),
15569 revoked: report.revoked.len(),
15570 failed: report.failed.len(),
15571 deferred_commits: report.deferred_commits,
15572 }),
15573 error_code: None,
15574 },
15575 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
15576 status: "failed".to_owned(),
15577 started_at_ms,
15578 completed_at_ms,
15579 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15580 counters: None,
15581 error_code: Some(Self::diagnostic_code(error_code)),
15582 },
15583 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
15584 status: "failed".to_owned(),
15585 started_at_ms,
15586 completed_at_ms,
15587 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15588 counters: None,
15589 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
15590 },
15591 });
15592 outcome
15593 }
15594
15595 pub fn prepare_sync_round(
15598 &mut self,
15599 url_capable: bool,
15600 ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
15601 if !self.transport_enabled {
15602 return Err(Box::new(SyncOutcome::Failed {
15603 error_code: "sync.offline".into(),
15604 message: "transport gate is closed".into(),
15605 details: None,
15606 }));
15607 }
15608 if self.active_round.is_some() {
15609 return Err(Box::new(SyncOutcome::Failed {
15610 error_code: "client.round_in_progress".into(),
15611 message: "a sync round is already in flight".into(),
15612 details: None,
15613 }));
15614 }
15615 self.storage_failure.borrow_mut().take();
15616 self.progress.start();
15617 self.round_retry_delay_ms = None;
15618 let started_at_ms = self.clock_now_ms();
15619 let prepared = (|| {
15620 if self.stopped {
15621 return Err(Box::new(SyncOutcome::Ok(SyncReport {
15622 schema_floor: self.schema_floor.clone(),
15623 ..SyncReport::default()
15624 })));
15625 }
15626 if self.security_preflight {
15627 return Err(Box::new(SyncOutcome::Failed {
15628 error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
15629 message: "security preflight must complete before syncing".into(),
15630 details: None,
15631 }));
15632 }
15633 self.set_sync_needed(false, false);
15634 self.drain_pending_evictions()
15635 .and_then(|_| self.drop_incompatible_outbox())
15636 .and_then(|_| self.drop_unencodable_outbox(true, None))
15637 .map_err(|message| {
15638 Box::new(SyncOutcome::Failed {
15639 error_code: "storage.failed".into(),
15640 message,
15641 details: None,
15642 })
15643 })?;
15644 let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
15645 self.pending_blob_uploads().map_err(|e| {
15646 Box::new(SyncOutcome::Failed {
15647 error_code: e.code,
15648 message: e.message,
15649 details: e.details,
15650 })
15651 })?
15652 } else {
15653 Vec::new()
15654 };
15655 if self.realtime_state != RealtimeState::Connected
15656 && self.realtime_policy == RealtimePolicy::Required
15657 {
15658 let retry_delay_ms = self.schedule_background_retry();
15659 self.realtime_retry_delay_ms = Some(retry_delay_ms);
15660 return Err(Box::new(SyncOutcome::RealtimeUnavailable {
15661 state: self.realtime_state,
15662 reason_code: self.realtime_reason_code.clone(),
15663 retry_delay_ms,
15664 }));
15665 }
15666 let (message, meta) = self.build_request(url_capable)?;
15667 let id = uuid::Uuid::new_v4();
15668 self.active_round = Some(id);
15669 Ok(crate::PreparedSyncRound {
15670 id,
15671 started_at_ms,
15672 message,
15673 meta,
15674 uploads: uploads.into(),
15675 realtime: self.realtime_state == RealtimeState::Connected,
15676 scopes: self
15677 .subs
15678 .iter()
15679 .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
15680 .collect(),
15681 #[cfg(feature = "bench-internals")]
15682 benchmark_phases: self.benchmark_phases.clone(),
15683 progress: self.progress.clone(),
15684 fixed_now: self.now_ms,
15685 round_deadline_at: None,
15686 })
15687 })();
15688 prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
15689 }
15690
15691 pub fn cancel_sync_round(&mut self) {
15694 self.active_round = None;
15695 }
15696
15697 pub fn apply_sync_round(
15700 &mut self,
15701 completed: crate::CompletedSyncRound,
15702 ) -> crate::AppliedSyncRound {
15703 let prepared = completed.prepared;
15704 if self.active_round != Some(prepared.id) || self.security_preflight {
15705 return crate::AppliedSyncRound::Complete {
15706 outcome: SyncOutcome::Failed {
15707 error_code: "client.round_cancelled".into(),
15708 message: "sync round belongs to a released client context".into(),
15709 details: None,
15710 },
15711 controls: Vec::new(),
15712 more: false,
15713 bootstrap_advanced: false,
15714 };
15715 }
15716 self.storage_failure.borrow_mut().take();
15717 let (response, mut downloads, transport_failed) = match completed.exchange {
15718 crate::round::ExchangeResult::Upload { id, result } => {
15719 let result = result.and_then(|()| {
15720 self.conn
15721 .execute(
15722 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
15723 rusqlite::params![id],
15724 )
15725 .map(|_| ())
15726 .map_err(|error| {
15727 TransportError::new(
15728 "client.failed",
15729 Self::sqlite_failure(&self.storage_failure, error),
15730 )
15731 })
15732 });
15733 match result {
15734 Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
15735 Err(TransportError {
15736 code,
15737 message,
15738 details,
15739 }) => {
15740 self.active_round = None;
15741 if Self::retryable_failure_code(&code) {
15742 self.schedule_background_retry();
15743 }
15744 let outcome = self.finish_sync_round(
15745 prepared.started_at_ms,
15746 SyncOutcome::Failed {
15747 error_code: code,
15748 message,
15749 details,
15750 },
15751 );
15752 return crate::AppliedSyncRound::Complete {
15753 outcome,
15754 controls: Vec::new(),
15755 more: false,
15756 bootstrap_advanced: false,
15757 };
15758 }
15759 }
15760 }
15761 crate::round::ExchangeResult::Reply {
15762 response,
15763 downloads,
15764 transport_failed,
15765 } => (response, downloads, transport_failed),
15766 };
15767 self.active_round = None;
15768 let before = self
15769 .subs
15770 .iter()
15771 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
15772 .collect::<Vec<_>>();
15773 let outcome = (|| {
15774 let response = match response {
15775 Ok(response) => response,
15776 Err(TransportError {
15777 code,
15778 message,
15779 details,
15780 }) => {
15781 if transport_failed {
15782 if prepared.realtime {
15783 self.set_realtime_state(RealtimeState::Lost, Some(&code));
15784 }
15785 self.record_lease_error(&code);
15786 }
15787 if Self::retryable_failure_code(&code) {
15788 let delay = self.schedule_background_retry();
15789 if prepared.realtime && transport_failed {
15790 self.realtime_retry_delay_ms = Some(delay);
15791 }
15792 }
15793 return SyncOutcome::Failed {
15794 error_code: code,
15795 message,
15796 details,
15797 };
15798 }
15799 };
15800 let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
15801 if let SyncOutcome::Ok(report) = &mut outcome {
15802 report.deferred_commits = prepared.meta.deferred_commits;
15803 }
15804 match &outcome {
15805 SyncOutcome::Ok(_) => self.reset_background_retry(),
15806 SyncOutcome::Failed { error_code, .. }
15807 if Self::retryable_failure_code(error_code) =>
15808 {
15809 self.schedule_background_retry();
15810 }
15811 _ => {}
15812 }
15813 if prepared.meta.deferred_commits > 0 {
15814 self.set_sync_needed(true, true);
15815 }
15816 self.previous_version_lifetime_check();
15817 outcome
15818 })();
15819 let more = self.transport_enabled
15820 && matches!(&outcome, SyncOutcome::Ok(report)
15821 if report.schema_floor.is_none()
15822 && (!report.bootstrapping.is_empty()
15823 || report.commits_applied > 0
15824 || report.segment_rows_applied > 0
15825 || !report.resets.is_empty()
15826 || report.deferred_commits > 0
15827 || self.sync_needed));
15828 let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
15829 && before
15830 != self
15831 .subs
15832 .iter()
15833 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
15834 .collect::<Vec<_>>();
15835 crate::AppliedSyncRound::Complete {
15836 outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
15837 controls: if self.transport_enabled {
15838 downloads.controls
15839 } else {
15840 Vec::new()
15841 },
15842 more,
15843 bootstrap_advanced,
15844 }
15845 }
15846
15847 pub fn sync_until_idle(
15848 &mut self,
15849 transport: &mut dyn Transport,
15850 max_rounds: Option<u32>,
15851 ) -> SyncOutcome {
15852 if max_rounds == Some(0) {
15855 return SyncOutcome::Failed {
15856 details: None,
15857 error_code: "sync.invalid_request".into(),
15858 message: "maxRounds must be a positive integer".into(),
15859 };
15860 }
15861 let rounds = max_rounds.unwrap_or(20);
15862 let mut aggregate = SyncReport::default();
15863 let mut spent = 0;
15864 while spent < rounds {
15865 spent += 1;
15866 let before = self
15867 .subs
15868 .iter()
15869 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
15870 .collect::<Vec<_>>();
15871 match self.sync(transport) {
15872 SyncOutcome::Failed {
15873 error_code,
15874 message,
15875 details,
15876 } => {
15877 return SyncOutcome::Failed {
15878 details,
15879 error_code,
15880 message,
15881 };
15882 }
15883 outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
15884 SyncOutcome::BudgetExhausted(_) => {
15885 unreachable!("sync() never exhausts a round budget")
15886 }
15887 SyncOutcome::Ok(report) => {
15888 if max_rounds.is_none()
15889 && report.segment_rows_applied > 0
15890 && !report.bootstrapping.is_empty()
15891 && before
15892 != self
15893 .subs
15894 .iter()
15895 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
15896 .collect::<Vec<_>>()
15897 {
15898 spent = 0;
15899 }
15900 let stop = !self.transport_enabled() || report.schema_floor.is_some();
15903 aggregate.merge(&report);
15904 if stop {
15905 return SyncOutcome::Ok(aggregate);
15906 }
15907 let more = !report.bootstrapping.is_empty()
15913 || report.commits_applied > 0
15914 || report.segment_rows_applied > 0
15915 || !report.resets.is_empty()
15916 || report.deferred_commits > 0
15917 || self.sync_needed;
15918 if !more {
15919 return SyncOutcome::Ok(aggregate);
15920 }
15921 }
15922 }
15923 }
15924 SyncOutcome::BudgetExhausted(aggregate)
15927 }
15928
15929 fn process_response(
15930 &mut self,
15931 transport: &mut dyn Transport,
15932 response: Message,
15933 meta: &RequestMeta,
15934 ) -> SyncOutcome {
15935 #[cfg(feature = "bench-internals")]
15936 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
15937 let mut report = SyncReport {
15938 pushed: meta.pushed_ids.len() as u32,
15939 ..SyncReport::default()
15940 };
15941 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
15942 HashMap::new();
15943 let pushed_ids = meta
15944 .pushed_ids
15945 .iter()
15946 .map(String::as_str)
15947 .collect::<HashSet<_>>();
15948 let mut last_final_push_result_id: Option<String> = None;
15949 for frame in &response.frames {
15950 match frame {
15951 Frame::PushResultDetails {
15952 client_commit_id,
15953 entries,
15954 } => {
15955 let details = rejection_details_by_commit
15956 .entry(client_commit_id.clone())
15957 .or_default();
15958 for entry in entries {
15959 let parsed = match RejectionDetails::parse(&entry.details.0) {
15960 Ok(value) => value,
15961 Err(message) => {
15962 return SyncOutcome::Failed {
15963 details: None,
15964 error_code: "sync.invalid_request".to_owned(),
15965 message,
15966 };
15967 }
15968 };
15969 details.insert(entry.op_index, parsed);
15970 }
15971 }
15972 Frame::PushResult {
15973 client_commit_id,
15974 status,
15975 results,
15976 ..
15977 } if pushed_ids.contains(client_commit_id.as_str())
15978 && Self::push_result_is_final(*status, results) =>
15979 {
15980 last_final_push_result_id = Some(client_commit_id.clone());
15981 }
15982 _ => {}
15983 }
15984 }
15985 let mut frames = response.frames.into_iter().peekable();
15986 if response.wire_version < 2 {
15987 return SyncOutcome::Failed {
15988 details: None,
15989 error_code: "client.invalid_host_response".to_owned(),
15990 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
15991 };
15992 }
15993 match frames.next() {
15994 Some(Frame::RespHeader {
15995 required_schema_version,
15996 latest_schema_version,
15997 log_epoch,
15998 reset_required,
15999 }) => {
16000 let Some(log_epoch) = log_epoch else {
16001 return SyncOutcome::Failed {
16002 details: None,
16003 error_code: "client.invalid_host_response".to_owned(),
16004 message: "response header omits logEpoch".to_owned(),
16005 };
16006 };
16007 let Some(reset_required) = reset_required else {
16008 return SyncOutcome::Failed {
16009 details: None,
16010 error_code: "client.invalid_host_response".to_owned(),
16011 message: "response header omits resetRequired".to_owned(),
16012 };
16013 };
16014 if let Some(required) = required_schema_version {
16015 let floor = SchemaFloor {
16018 required_schema_version: Some(required),
16019 latest_schema_version,
16020 };
16021 self.set_schema_floor(Some(floor.clone()));
16022 report.schema_floor = Some(floor);
16023 return SyncOutcome::Ok(report);
16024 }
16025 if reset_required {
16026 if frames.next().is_some() {
16027 return SyncOutcome::Failed {
16028 details: None,
16029 error_code: "client.invalid_host_response".to_owned(),
16030 message: "log-epoch reset response contains body frames".to_owned(),
16031 };
16032 }
16033 match self.run_log_epoch_reset(&log_epoch) {
16034 Ok(resets) => {
16035 report.resets = resets;
16036 return SyncOutcome::Ok(report);
16037 }
16038 Err(message) => {
16039 return SyncOutcome::Failed {
16040 details: None,
16041 error_code: "sync.local_corrupt".to_owned(),
16042 message,
16043 };
16044 }
16045 }
16046 }
16047 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
16048 return SyncOutcome::Failed {
16049 details: None,
16050 error_code: "client.invalid_host_response".to_owned(),
16051 message: "server changed logEpoch without requiring a reset".to_owned(),
16052 };
16053 }
16054 }
16055 _ => {
16056 return SyncOutcome::Failed {
16057 details: None,
16058 error_code: "sync.invalid_request".to_owned(),
16059 message: "response does not start with RESP_HEADER".to_owned(),
16060 };
16061 }
16062 }
16063
16064 let mut failure: Option<(String, String)> = None;
16065 while let Some(frame) = frames.next() {
16066 match frame {
16067 frame @ Frame::PushResult { .. } => {
16068 let successful = matches!(
16069 &frame,
16070 Frame::PushResult {
16071 status: PushStatus::Applied | PushStatus::Cached,
16072 ..
16073 }
16074 );
16075 let mut results = vec![frame];
16076 if successful {
16077 while matches!(
16078 frames.peek(),
16079 Some(Frame::PushResult {
16080 status: PushStatus::Applied | PushStatus::Cached,
16081 ..
16082 })
16083 ) {
16084 if let Some(next) = frames.next() {
16085 results.push(next);
16086 }
16087 }
16088 }
16089 if self
16090 .handle_push_results(
16091 &results,
16092 &pushed_ids,
16093 &rejection_details_by_commit,
16094 &mut report,
16095 last_final_push_result_id.as_deref(),
16096 )
16097 .is_err()
16098 {
16099 failure = Some((
16100 "client.outcome_persistence_failed".to_owned(),
16101 "local commit outcome could not be persisted".to_owned(),
16102 ));
16103 break;
16104 }
16105 }
16106 Frame::PushResultDetails { .. } => {}
16107 Frame::SubStart {
16108 id,
16109 status,
16110 reason_code,
16111 effective_scopes,
16112 bootstrap: _,
16113 } => {
16114 let mut body = Vec::new();
16115 let mut sub_end: Option<(i64, Option<String>)> = None;
16116 for inner in frames.by_ref() {
16117 match inner {
16118 Frame::SubEnd {
16119 next_cursor,
16120 bootstrap_state,
16121 } => {
16122 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
16123 break;
16124 }
16125 Frame::Unknown { .. } => {}
16126 other => body.push(other),
16127 }
16128 }
16129 if let Err(SectionError::Abort(code, message)) = self.process_section(
16130 transport,
16131 &id,
16132 status,
16133 &reason_code,
16134 effective_scopes,
16135 body,
16136 sub_end,
16137 meta,
16138 &mut report,
16139 ) {
16140 failure = Some((code, message));
16141 break;
16142 }
16143 }
16144 Frame::Lease {
16145 lease_id,
16146 expires_at_ms,
16147 } => {
16148 self.set_lease_state(Some(LeaseState {
16151 lease_id: Some(lease_id),
16152 expires_at_ms: Some(expires_at_ms),
16153 error_code: None,
16154 }));
16155 }
16156 Frame::Error { code, message, .. } => {
16157 failure = Some((code, message));
16160 break;
16161 }
16162 Frame::Unknown { .. } => {}
16163 _ => {
16164 failure = Some((
16165 "sync.invalid_request".to_owned(),
16166 "unexpected frame in response".to_owned(),
16167 ));
16168 break;
16169 }
16170 }
16171 }
16172
16173 if let Some((error_code, message)) = failure {
16174 return SyncOutcome::Failed {
16175 details: None,
16176 error_code,
16177 message,
16178 };
16179 }
16180 if let Err(message) = self.rebuild_overlay_if_dirty() {
16183 return SyncOutcome::Failed {
16184 details: None,
16185 error_code: "storage.failed".into(),
16186 message,
16187 };
16188 }
16189 if let Err(message) = self.drain_pending_evictions() {
16192 return SyncOutcome::Failed {
16193 details: None,
16194 error_code: "storage.failed".into(),
16195 message,
16196 };
16197 }
16198 self.ack_after_pull(transport);
16199 if self.upgrading && report.bootstrapping.is_empty() {
16202 self.set_upgrading(false);
16203 }
16204 SyncOutcome::Ok(report)
16205 }
16206
16207 fn handle_push_results(
16210 &mut self,
16211 frames: &[Frame],
16212 pushed_ids: &HashSet<&str>,
16213 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
16214 report: &mut SyncReport,
16215 last_final_push_result_id: Option<&str>,
16216 ) -> Result<(), String> {
16217 self.begin_observation("syncular_push_results")?;
16218 let previous_failed = self.failed_commits.clone();
16219 let previous_sync_needed = self.sync_needed;
16220 let conflict_count = self.conflicts.len();
16221 let rejection_count = self.rejections.len();
16222 let applied_count = report.applied.len();
16223 let rejected_count = report.rejected.len();
16224 let retryable_count = report.retryable.len();
16225 let was_dirty = self.overlay_dirty.snapshot();
16226 let mut removed = Vec::new();
16227 let mut batch = ChangeAccumulator::default();
16228 let persisted = (|| {
16229 for frame in frames {
16230 let Frame::PushResult {
16231 client_commit_id,
16232 status,
16233 results,
16234 commit_seq,
16235 } = frame
16236 else {
16237 return Err("unexpected acknowledgement frame".to_owned());
16238 };
16239 if !pushed_ids.contains(client_commit_id.as_str()) {
16240 continue;
16241 }
16242 let Some(index) = self
16243 .outbox
16244 .iter()
16245 .position(|commit| commit.client_commit_id == *client_commit_id)
16246 else {
16247 continue;
16248 };
16249 let status = *status;
16250 let rejection_details = rejection_details_by_commit.get(client_commit_id);
16251 let operations = self.outbox[index].ops.clone();
16252 match status {
16253 PushStatus::Applied | PushStatus::Cached => {
16254 let sequence =
16257 commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
16258 let affected: HashSet<_> = operations
16259 .iter()
16260 .map(|op| (op.table.as_str(), op.row_id.as_str()))
16261 .collect();
16262 let prior = self.acknowledged_operations(Some(&affected), None)?;
16263 for (op_index, operation) in operations.iter().enumerate() {
16264 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))
16265 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16266 if delivered {
16267 continue;
16268 }
16269 let table = self
16270 .schema
16271 .table(&operation.table)
16272 .ok_or_else(|| "sync.unknown_table".to_owned())?;
16273 let mut intended: Map<String, Value> = self
16274 .stored_row(table, &operation.row_id, true)?
16275 .map(|(row, _)| {
16276 table
16277 .columns
16278 .iter()
16279 .zip(&row)
16280 .map(|(column, value)| {
16281 (column.name.clone(), column_value_to_json(value))
16282 })
16283 .collect()
16284 })
16285 .unwrap_or_default();
16286 let mut scope_image = intended.clone();
16287 for op in prior
16288 .iter()
16289 .chain(
16290 self.failed_commits
16291 .iter()
16292 .flat_map(|(commit, _)| &commit.ops),
16293 )
16294 .chain(
16295 self.outbox
16296 .iter()
16297 .take(index)
16298 .flat_map(|commit| &commit.ops),
16299 )
16300 .chain(operations.iter().take(op_index + 1))
16301 {
16302 if op.table != operation.table || op.row_id != operation.row_id {
16303 continue;
16304 }
16305 if let Some(values) = &op.values {
16306 intended.extend(values.clone());
16307 } else {
16308 scope_image = intended.clone();
16309 intended.clear();
16310 }
16311 }
16312 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)",
16313 rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
16314 serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
16315 serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
16316 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16317 }
16318 let journal_results = results
16319 .iter()
16320 .map(|result| {
16321 let op_index = match result {
16322 OpResult::Applied { op_index }
16323 | OpResult::Conflict { op_index, .. }
16324 | OpResult::Error { op_index, .. } => *op_index,
16325 };
16326 CommitOperationOutcome::Applied { op_index }
16327 })
16328 .collect::<Vec<_>>();
16329 let outcome_status = if status == PushStatus::Applied {
16330 CommitOutcomeStatus::Applied
16331 } else {
16332 CommitOutcomeStatus::Cached
16333 };
16334 let persisted = self
16335 .persist_commit_outcome(
16336 client_commit_id,
16337 outcome_status,
16338 &journal_results,
16339 None,
16340 )
16341 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
16342 persisted?;
16343 for operation in &operations {
16344 self.overlay_dirty.table(&operation.table);
16345 }
16346 self.sync_needed = true;
16347 batch.status = true;
16348 batch.outcomes = true;
16349 }
16350 PushStatus::Rejected => {
16351 if results.iter().any(|result| {
16352 matches!(
16353 result,
16354 OpResult::Error {
16355 code,
16356 retryable: true,
16357 ..
16358 } if code == "sync.idempotency_cache_miss"
16359 )
16360 }) {
16361 report.retryable.push(client_commit_id.to_owned());
16364 continue;
16365 }
16366
16367 let mut journal_results = Vec::with_capacity(results.len());
16368 let mut conflicts = Vec::new();
16369 let mut rejections = Vec::new();
16370 for result in results {
16371 match result {
16372 OpResult::Applied { op_index } => {
16373 journal_results.push(CommitOperationOutcome::Applied {
16374 op_index: *op_index,
16375 });
16376 }
16377 OpResult::Conflict {
16378 op_index,
16379 code,
16380 message,
16381 server_version,
16382 server_row,
16383 conflict_columns: conflict_columns_bitmap,
16384 } => {
16385 let operation = operations
16386 .get(*op_index as usize)
16387 .map(CommitOperation::from);
16388 let (table, row_id) = operation
16389 .as_ref()
16390 .map(|op| (op.table.clone(), op.row_id.clone()))
16391 .unwrap_or_default();
16392 let server_row_json = self
16393 .schema
16394 .table(&table)
16395 .and_then(|t| {
16396 decode_row_bytes(t, server_row, &self.encryption)
16397 .ok()
16398 .map(|row| (t, row))
16399 })
16400 .map(|(t, row)| {
16401 let mut map = Map::new();
16402 for (i, column) in t.columns.iter().enumerate() {
16403 map.insert(
16404 column.name.clone(),
16405 column_value_to_json(
16406 row.get(i).unwrap_or(&None),
16407 ),
16408 );
16409 }
16410 map
16411 })
16412 .unwrap_or_default();
16413 let conflict_columns = self
16416 .schema
16417 .table(&table)
16418 .map(|t| {
16419 t.columns
16420 .iter()
16421 .enumerate()
16422 .filter(|(index, _)| {
16423 conflict_columns_bitmap
16424 .get(index / 8)
16425 .is_some_and(|byte| {
16426 byte & (1 << (index % 8)) != 0
16427 })
16428 })
16429 .map(|(_, column)| column.name.clone())
16430 .collect::<Vec<_>>()
16431 })
16432 .unwrap_or_default();
16433 let conflict = ConflictRecord {
16434 client_commit_id: client_commit_id.to_owned(),
16435 op_index: *op_index,
16436 table,
16437 row_id,
16438 code: code.clone(),
16439 message: message.clone(),
16440 server_version: *server_version,
16441 server_row: server_row_json,
16442 conflict_columns,
16443 operation,
16444 };
16445 journal_results.push(CommitOperationOutcome::Conflict {
16446 conflict: conflict.clone(),
16447 });
16448 conflicts.push(conflict);
16449 }
16450 OpResult::Error {
16451 op_index,
16452 code,
16453 message,
16454 retryable,
16455 } => {
16456 let rejection = RejectionRecord {
16457 client_commit_id: client_commit_id.to_owned(),
16458 op_index: *op_index,
16459 code: code.clone(),
16460 message: message.clone(),
16461 retryable: *retryable,
16462 details: rejection_details
16463 .and_then(|details| details.get(op_index))
16464 .cloned(),
16465 operation: operations
16466 .get(*op_index as usize)
16467 .map(CommitOperation::from),
16468 };
16469 journal_results.push(CommitOperationOutcome::Error {
16470 rejection: rejection.clone(),
16471 });
16472 rejections.push(rejection);
16473 }
16474 }
16475 }
16476 if self.retain_failed_commits {
16477 self.retain_failed_commit(client_commit_id, &operations)?;
16478 }
16479 let outcome_status = if conflicts.is_empty() {
16480 CommitOutcomeStatus::Rejected
16481 } else {
16482 CommitOutcomeStatus::Conflict
16483 };
16484 let persisted = self
16485 .persist_commit_outcome(
16486 client_commit_id,
16487 outcome_status,
16488 &journal_results,
16489 Some(&operations),
16490 )
16491 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
16492 persisted?;
16493 batch.conflicts = !conflicts.is_empty();
16494 batch.rejections = !rejections.is_empty();
16495 batch.status = true;
16496 batch.outcomes = true;
16497 self.conflicts.extend(conflicts);
16498 self.rejections.extend(rejections);
16499 for operation in &operations {
16500 self.overlay_dirty.table(&operation.table);
16501 }
16502 }
16503 }
16504 removed.push((index, self.outbox.remove(index)));
16505 if batch.status {
16506 for operation in &operations {
16507 if !self.record_row_scopes(
16508 &mut batch,
16509 &operation.table,
16510 &operation.row_id,
16511 false,
16512 ) {
16513 batch.table(&operation.table);
16514 }
16515 }
16516 }
16517 match status {
16518 PushStatus::Applied | PushStatus::Cached => {
16519 report.applied.push(client_commit_id.to_owned())
16520 }
16521 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
16522 }
16523 }
16524 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)) {
16525 self.prune_commit_outcomes()?;
16526 }
16527 self.rebuild_overlay_if_dirty()?;
16528 self.finish_observation("syncular_push_results", batch)
16529 })();
16530 if let Err(error) = persisted {
16531 self.rollback_observation("syncular_push_results");
16532 self.failed_commits = previous_failed;
16533 self.sync_needed = previous_sync_needed;
16534 for (index, commit) in removed.into_iter().rev() {
16535 self.outbox.insert(index, commit);
16536 }
16537 self.conflicts.truncate(conflict_count);
16538 self.rejections.truncate(rejection_count);
16539 self.overlay_dirty.restore(was_dirty.clone());
16540 report.applied.truncate(applied_count);
16541 report.rejected.truncate(rejected_count);
16542 report.retryable.truncate(retryable_count);
16543 return Err(error);
16544 }
16545 if !previous_sync_needed && self.sync_needed {
16546 self.sync_intent_queue.push_back(SyncIntent::Interactive);
16547 }
16548 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
16549 Ok(())
16550 }
16551
16552 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
16553 status != PushStatus::Rejected
16554 || !results.iter().any(|result| {
16555 matches!(
16556 result,
16557 OpResult::Error {
16558 code,
16559 retryable: true,
16560 ..
16561 } if code == "sync.idempotency_cache_miss"
16562 )
16563 })
16564 }
16565
16566 #[allow(clippy::too_many_arguments)]
16569 fn process_section(
16570 &mut self,
16571 transport: &mut dyn Transport,
16572 id: &str,
16573 status: SubStatus,
16574 reason_code: &str,
16575 effective_scopes: Vec<(String, Vec<String>)>,
16576 body: Vec<Frame>,
16577 sub_end: Option<(i64, Option<String>)>,
16578 meta: &RequestMeta,
16579 report: &mut SyncReport,
16580 ) -> Result<(), SectionError> {
16581 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
16582 return Ok(()); };
16584 if status != SubStatus::Active && sub_end.is_none() {
16585 return Err(SectionError::Abort(
16586 "sync.invalid_request".into(),
16587 "subscription section without SUB_END".into(),
16588 ));
16589 }
16590 match status {
16591 SubStatus::Revoked => {
16592 self.begin_observation("syncular_revocation")
16593 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
16594 let previous = self.subs[sub_index].clone();
16595 let was_dirty = self.overlay_dirty.snapshot();
16596 let previous_outbox = self.outbox.clone();
16597 let previous_failed = self.failed_commits.clone();
16598 let previous_rejections = self.rejections.clone();
16599 let previous_conflicts = self.conflicts.clone();
16600 let revoked_count = report.revoked.len();
16601 let failed_count = report.failed.len();
16602 let result = (|| {
16603 let mut batch = ChangeAccumulator::default();
16604 let registered = self.window_unit_by_sub_id(id);
16605 let (table, effective) = {
16607 let sub = &self.subs[sub_index];
16608 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
16609 };
16610 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
16611 SectionError::Abort(
16612 "sync.unknown_table".to_owned(),
16613 "revoked scope references an unknown table".to_owned(),
16614 )
16615 })?;
16616 let dropped = self
16617 .drop_failed_commits(|name, values| {
16618 name == table
16619 && !effective.is_empty()
16620 && effective.iter().all(|(variable, allowed)| {
16621 scoped_table
16622 .scope_column(variable)
16623 .and_then(|column| values.get(column))
16624 .and_then(Value::as_str)
16625 .is_some_and(|value| {
16626 allowed.iter().any(|allowed| allowed == value)
16627 })
16628 })
16629 })
16630 .map_err(|message| {
16631 SectionError::Abort("storage.failed".to_owned(), message)
16632 })?;
16633 for commit in dropped {
16634 batch.conflicts = true;
16635 batch.rejections = true;
16636 batch.outcomes = true;
16637 for operation in commit.ops {
16638 batch.table(&operation.table);
16639 }
16640 }
16641 let purged = self.purge_scope_rows(&table, &effective);
16642 match purged {
16643 Ok(()) => {
16644 self.record_scope_map(&mut batch, &table, &effective);
16645 let sub = &mut self.subs[sub_index];
16646 sub.state = SubState::Revoked;
16647 sub.reason_code = Some(if reason_code.is_empty() {
16648 "sync.scope_revoked".to_owned()
16649 } else {
16650 reason_code.to_owned()
16651 });
16652 report.revoked.push(id.to_owned());
16653 let doomed_effective = effective;
16654 let sub_table = table;
16655 self.persist_sub(&self.subs[sub_index].clone())
16656 .map_err(|message| {
16657 SectionError::Abort("storage.failed".into(), message)
16658 })?;
16659 let dropped = self
16660 .drop_doomed_outbox(&sub_table, &doomed_effective)
16661 .map_err(|message| {
16662 SectionError::Abort("storage.failed".to_owned(), message)
16663 })?;
16664 if dropped {
16665 batch.status = true;
16666 batch.rejections = true;
16667 batch.outcomes = true;
16668 }
16669 self.delete_unreferenced_cached_blobs().map_err(|message| {
16672 SectionError::Abort("storage.failed".into(), message)
16673 })?;
16674 }
16675 Err(()) => {
16676 let sub = &mut self.subs[sub_index];
16679 sub.state = SubState::Failed;
16680 sub.reason_code = Some("sync.scope_revoked".to_owned());
16681 report.failed.push(id.to_owned());
16682 self.persist_sub(&self.subs[sub_index].clone())
16683 .map_err(|message| {
16684 SectionError::Abort("storage.failed".into(), message)
16685 })?;
16686 }
16687 }
16688 if let Some((base_key, unit)) = registered {
16689 batch.window(&base_key, &self.subs[sub_index].table, &unit);
16690 }
16691 self.rebuild_overlay_if_dirty()
16692 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
16693 self.finish_observation("syncular_revocation", batch)
16694 .map_err(|message| {
16695 SectionError::Abort("storage.failed".to_owned(), message)
16696 })?;
16697 Ok(())
16698 })();
16699 if result.is_err() {
16700 self.rollback_observation("syncular_revocation");
16701 self.subs[sub_index] = previous;
16702 self.overlay_dirty.restore(was_dirty.clone());
16703 self.outbox = previous_outbox;
16704 self.failed_commits = previous_failed;
16705 self.rejections = previous_rejections;
16706 self.conflicts = previous_conflicts;
16707 report.revoked.truncate(revoked_count);
16708 report.failed.truncate(failed_count);
16709 }
16710 result
16711 }
16712 SubStatus::Reset => {
16713 self.begin_observation("syncular_reset")
16714 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
16715 let previous = self.subs[sub_index].clone();
16716 let was_dirty = self.overlay_dirty.snapshot();
16717 let reset_count = report.resets.len();
16718 let result = (|| {
16719 let mut batch = ChangeAccumulator::default();
16720 let registered = self.window_unit_by_sub_id(id);
16721 let sub = &mut self.subs[sub_index];
16724 sub.cursor = -1;
16725 sub.bootstrap_state = None;
16726 sub.reason_code = Some(if reason_code.is_empty() {
16727 "sync.cursor_expired".to_owned()
16728 } else {
16729 reason_code.to_owned()
16730 });
16731 report.resets.push(id.to_owned());
16732 self.persist_sub(&self.subs[sub_index].clone())
16733 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
16734 if let Some((base_key, unit)) = registered {
16735 batch.window(&base_key, &self.subs[sub_index].table, &unit);
16736 }
16737 self.finish_observation("syncular_reset", batch)
16738 .map_err(|message| {
16739 SectionError::Abort("storage.failed".to_owned(), message)
16740 })?;
16741 Ok(())
16742 })();
16743 if result.is_err() {
16744 self.rollback_observation("syncular_reset");
16745 self.subs[sub_index] = previous;
16746 self.overlay_dirty.restore(was_dirty.clone());
16747 report.resets.truncate(reset_count);
16748 }
16749 result
16750 }
16751 SubStatus::Active => {
16752 let fresh = meta
16753 .fresh
16754 .iter()
16755 .find(|(fid, _)| fid == id)
16756 .map(|(_, fresh)| *fresh)
16757 .unwrap_or(false);
16758 let previous = self.subs[sub_index].clone();
16759 self.subs[sub_index].effective = Some(effective_scopes);
16760 let outcome = self
16761 .apply_section_body(transport, sub_index, body, fresh, meta, report)
16762 .and_then(|()| {
16763 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
16764 SectionError::Abort(
16765 "sync.invalid_request".into(),
16766 "subscription section without SUB_END".into(),
16767 )
16768 })?;
16769 self.apply_sub_end(sub_index, cursor, bootstrap_state)
16770 .map_err(|message| {
16771 SectionError::Abort("storage.failed".into(), message)
16772 })
16773 });
16774 match outcome {
16775 Ok(()) => {
16776 if self.subs[sub_index].bootstrap_state.is_some() {
16777 report.bootstrapping.push(id.to_owned());
16778 }
16779 Ok(())
16780 }
16781 Err(SectionError::FailClosed) => {
16782 self.subs[sub_index] = previous.clone();
16783 self.begin_observation("syncular_section_failure")
16784 .map_err(|message| {
16785 SectionError::Abort("storage.failed".into(), message)
16786 })?;
16787 let persisted = (|| {
16788 let mut batch = ChangeAccumulator::default();
16789 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
16790 batch.window(&base_key, &self.subs[sub_index].table, &unit);
16791 }
16792 self.subs[sub_index].state = SubState::Failed;
16793 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
16794 self.persist_sub(&self.subs[sub_index])?;
16795 self.finish_observation("syncular_section_failure", batch)
16796 })();
16797 if let Err(message) = persisted {
16798 self.rollback_observation("syncular_section_failure");
16799 self.subs[sub_index] = previous;
16800 return Err(SectionError::Abort("storage.failed".into(), message));
16801 }
16802 report.failed.push(id.to_owned());
16803 Ok(())
16804 }
16805 Err(error) => {
16806 self.subs[sub_index] = previous;
16809 Err(error)
16810 }
16811 }
16812 }
16813 }
16814 }
16815
16816 fn apply_sub_end(
16817 &mut self,
16818 sub_index: usize,
16819 cursor: i64,
16820 bootstrap_state: Option<String>,
16821 ) -> Result<(), String> {
16822 #[cfg(feature = "bench-internals")]
16823 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
16824 let previous = self.subs[sub_index].clone();
16825 self.begin_observation("syncular_sub_end")?;
16826 let persisted = (|| {
16827 let mut batch = ChangeAccumulator::default();
16828 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
16829 && cursor >= 0
16830 && bootstrap_state.is_none();
16831 if completed {
16832 let table = self
16833 .schema
16834 .table(&previous.table)
16835 .ok_or_else(|| "sync.unknown_table".to_owned())?;
16836 let effective = previous.effective.as_deref().unwrap_or(&[]);
16837 let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
16838 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16839 let rows = statement
16840 .query_map(rusqlite::params![previous.table, cursor], |row| {
16841 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
16842 })
16843 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16844 let mut covered = HashSet::new();
16845 for row in rows {
16846 let (id, intent) =
16847 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16848 let values: Map<String, Value> = serde_json::from_str(&intent)
16849 .map_err(|_| "sync.local_corrupt".to_owned())?;
16850 if !effective.is_empty()
16851 && effective.iter().all(|(variable, allowed)| {
16852 table
16853 .scope_column(variable)
16854 .and_then(|column| values.get(column))
16855 .is_some_and(|value| {
16856 allowed.contains(
16857 &value
16858 .as_str()
16859 .map_or_else(|| value.to_string(), str::to_owned),
16860 )
16861 })
16862 })
16863 {
16864 covered.insert(id);
16865 }
16866 }
16867 drop(statement);
16868 for id in covered {
16869 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
16870 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16871 self.overlay_dirty.table(&previous.table);
16872 batch.table(&previous.table);
16873 }
16874 self.rebuild_overlay_if_dirty()?;
16875 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
16876 batch.window(&base_key, &previous.table, &unit);
16877 }
16878 }
16879 let sub = &mut self.subs[sub_index];
16880 sub.cursor = cursor;
16881 sub.bootstrap_state = bootstrap_state;
16882 sub.reason_code = None;
16885 sub.synced_once = true;
16886 self.persist_sub(&self.subs[sub_index])?;
16887 self.finish_observation("syncular_sub_end", batch)
16888 })();
16889 if persisted.is_err() {
16890 self.rollback_observation("syncular_sub_end");
16891 self.subs[sub_index] = previous;
16892 }
16893 persisted
16894 }
16895
16896 fn apply_section_body(
16897 &mut self,
16898 transport: &mut dyn Transport,
16899 sub_index: usize,
16900 body: Vec<Frame>,
16901 fresh: bool,
16902 meta: &RequestMeta,
16903 report: &mut SyncReport,
16904 ) -> Result<(), SectionError> {
16905 let mut saw_segment = false;
16906 for frame in body {
16907 match frame {
16908 Frame::Commit {
16909 tables,
16910 changes,
16911 commit_seq,
16912 ..
16913 } => {
16914 self.apply_commit_frame(&tables, &changes, Some(commit_seq))
16915 .map_err(|(c, m)| SectionError::Abort(c, m))?;
16916 report.commits_applied += 1;
16917 }
16918 Frame::SegmentInline { payload } => {
16919 let segment = decode_rows_segment(&payload)
16920 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
16921 self.progress.update(|p| {
16922 p.phase = ProgressPhase::Import;
16923 p.subscription_id = Some(self.subs[sub_index].id.clone());
16924 p.table = Some(self.subs[sub_index].table.clone());
16925 p.segment_id = None;
16926 p.bytes_received = payload.len() as u64;
16927 p.bytes_total = Some(payload.len() as u64);
16928 p.rows_processed = 0;
16929 p.rows_total =
16930 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
16931 });
16932 let first = !saw_segment;
16933 saw_segment = true;
16934 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
16935 report.segment_rows_applied += applied;
16936 }
16937 Frame::SegmentRef {
16938 segment_id,
16939 byte_length,
16940 media_type,
16941 table,
16942 row_count,
16943 as_of_commit_seq,
16944 scope_digest,
16945 row_cursor,
16946 next_row_cursor,
16947 url,
16948 url_expires_at_ms,
16949 ..
16950 } => {
16951 let advertised = match media_type {
16954 MediaType::Rows => {
16955 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
16956 || meta.accept & ACCEPT_INLINE_ROWS != 0
16957 }
16958 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
16959 };
16960 if !advertised {
16961 return Err(SectionError::Abort(
16962 "sync.invalid_request".to_owned(),
16963 format!(
16964 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
16965 media_type.name()
16966 ),
16967 ));
16968 }
16969 self.progress.update(|p| {
16970 p.phase = ProgressPhase::Download;
16971 p.subscription_id = Some(self.subs[sub_index].id.clone());
16972 p.table = Some(table.clone());
16973 p.segment_id = Some(segment_id.clone());
16974 p.bytes_received = 0;
16975 p.bytes_total = u64::try_from(byte_length).ok();
16976 p.rows_processed = 0;
16977 p.rows_total = u64::try_from(row_count).ok();
16978 });
16979 let observer = self.progress.clone();
16980 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
16981 let bytes = if let Some(url) = url {
16982 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
16987 return Err(SectionError::Abort(
16988 "sync.invalid_request".to_owned(),
16989 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
16990 .to_owned(),
16991 ));
16992 }
16993 if url_expires_at_ms.is_some_and(|exp| {
16995 exp <= transport
16996 .segment_fetch_started_at(&url)
16997 .unwrap_or_else(|| self.clock_now_ms())
16998 }) {
16999 return Err(SectionError::Abort(
17000 "sync.segment_expired".to_owned(),
17001 format!(
17002 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
17003 ),
17004 ));
17005 }
17006 transport
17007 .fetch_url(&url, &mut on_progress)
17008 .map_err(|e| SectionError::Abort(e.code, e.message))?
17009 } else {
17010 let requested_scopes_json =
17011 canonical_scope_json(&self.subs[sub_index].requested);
17012 transport
17013 .download_segment(
17014 &SegmentRequest {
17015 segment_id: segment_id.clone(),
17016 table,
17017 requested_scopes_json,
17018 },
17019 &mut on_progress,
17020 )
17021 .map_err(|e| SectionError::Abort(e.code, e.message))?
17022 };
17023 on_progress(bytes.len() as u64);
17025 let digest = Sha256::digest(&bytes);
17026 let expected = segment_id
17027 .strip_prefix("sha256:")
17028 .unwrap_or(segment_id.as_str());
17029 if bytes_to_hex(&digest) != expected {
17030 self.schedule_background_retry();
17032 return Err(SectionError::Abort(
17033 "sync.invalid_request".to_owned(),
17034 "segment bytes do not match the content address (§5.1)".to_owned(),
17035 ));
17036 }
17037 self.progress.update(|p| p.phase = ProgressPhase::Import);
17038 if media_type == MediaType::Sqlite {
17039 if row_cursor.is_some() || next_row_cursor.is_some() {
17042 return Err(SectionError::Abort(
17043 "sync.invalid_request".to_owned(),
17044 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
17045 .to_owned(),
17046 ));
17047 }
17048 let first = !saw_segment;
17049 saw_segment = true;
17050 let applied = self.apply_sqlite_segment(
17051 sub_index,
17052 &bytes,
17053 fresh && first,
17054 row_count,
17055 as_of_commit_seq,
17056 &scope_digest,
17057 )?;
17058 report.segment_rows_applied += applied;
17059 } else {
17060 let segment = decode_rows_segment(&bytes).map_err(|e| {
17061 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
17062 })?;
17063 let first = row_cursor.is_none();
17064 saw_segment = true;
17065 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
17066 report.segment_rows_applied += applied;
17067 }
17068 }
17069 Frame::Unknown { .. } => {}
17070 Frame::Error { code, message, .. } => {
17071 return Err(SectionError::Abort(code, message))
17072 }
17073 _ => {
17074 return Err(SectionError::Abort(
17075 "sync.invalid_request".to_owned(),
17076 "unexpected frame inside a subscription section".to_owned(),
17077 ));
17078 }
17079 }
17080 }
17081 Ok(())
17082 }
17083
17084 fn apply_commit_frame(
17086 &mut self,
17087 tables: &[String],
17088 changes: &[ssp2::model::Change],
17089 commit_seq: Option<i64>,
17090 ) -> Result<(), (String, String)> {
17091 #[cfg(feature = "bench-internals")]
17092 let _phase = self.benchmark_phases.start(Phase::CommitApply);
17093 let was_dirty = self.overlay_dirty.snapshot();
17094 self.begin_observation("syncular_commit")
17095 .map_err(|message| ("storage.failed".into(), message))?;
17096 let applied = (|| {
17097 let mut batch = ChangeAccumulator::default();
17098 self.record_commit_changes(&mut batch, tables, changes);
17099 if let Some(sequence) = commit_seq {
17100 for change in changes {
17101 let table = tables.get(change.table_index as usize).ok_or_else(|| {
17102 (
17103 "sync.invalid_request".to_owned(),
17104 "change tableIndex out of range".to_owned(),
17105 )
17106 })?;
17107 if self
17108 .outbox
17109 .iter()
17110 .flat_map(|commit| &commit.ops)
17111 .any(|op| op.table == *table && op.row_id == change.row_id)
17112 {
17113 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])
17114 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
17115 }
17116 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])
17117 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
17118 }
17119 }
17120 self.apply_commit_changes(tables, changes)?;
17121 self.rebuild_overlay_if_dirty()
17122 .map_err(|message| ("storage.failed".into(), message))?;
17123 self.finish_observation("syncular_commit", batch)
17124 .map_err(|message| ("storage.failed".into(), message))
17125 })();
17126 if applied.is_err() {
17127 self.rollback_observation("syncular_commit");
17128 self.overlay_dirty.restore(was_dirty.clone());
17129 }
17130 applied
17131 }
17132
17133 fn apply_commit_changes(
17134 &mut self,
17135 tables: &[String],
17136 changes: &[ssp2::model::Change],
17137 ) -> Result<(), (String, String)> {
17138 let mirror_visible = !self.overlay_dirty.get()
17143 && self.failed_commits.is_empty()
17144 && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
17145 || changes.iter().all(|change| {
17146 tables
17147 .get(change.table_index as usize)
17148 .and_then(|name| self.schema.table(name))
17149 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
17150 }));
17151 for change in changes {
17152 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
17153 (
17154 "sync.invalid_request".to_owned(),
17155 "change tableIndex out of range".to_owned(),
17156 )
17157 })?;
17158 let table = self.schema.table(table_name).ok_or_else(|| {
17159 (
17160 "sync.schema_mismatch".to_owned(),
17161 format!("change targets unknown table {table_name:?}"),
17162 )
17163 })?;
17164 match change.op {
17165 Op::Upsert => {
17166 let payload = change.row.as_ref().ok_or_else(|| {
17167 (
17168 "sync.invalid_request".to_owned(),
17169 "upsert change without row payload".to_owned(),
17170 )
17171 })?;
17172 let row = {
17174 #[cfg(feature = "bench-internals")]
17175 let _phase = self.benchmark_phases.start(Phase::RowDecode);
17176 decode_row_bytes(table, payload, &self.encryption)
17177 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
17178 };
17179 let version = change.row_version.unwrap_or(0);
17180 let table_name = table.name.clone();
17181 self.write_base_row(&table_name, &row, version)
17182 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17183 if mirror_visible {
17184 self.write_row(&visible_table(&table_name), &table_name, &row, version)
17185 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17186 }
17187 }
17188 Op::Delete => {
17189 self.delete_base_row(table_name, &change.row_id)
17190 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17191 if mirror_visible {
17192 let sql = format!(
17193 "DELETE FROM {} WHERE {}",
17194 visible_table(table_name),
17195 row_id_predicate(table)
17196 );
17197 self.conn
17198 .prepare_cached(&sql)
17199 .and_then(|mut statement| {
17200 statement.execute(rusqlite::params![change.row_id])
17201 })
17202 .map_err(|error| {
17203 ("sync.invalid_request".to_owned(), error.to_string())
17204 })?;
17205 }
17206 }
17207 }
17208 }
17209 if mirror_visible {
17210 {
17211 let changed_rows: HashSet<(&str, &str)> = changes
17212 .iter()
17213 .map(|change| {
17214 (
17215 tables[change.table_index as usize].as_str(),
17216 change.row_id.as_str(),
17217 )
17218 })
17219 .collect();
17220 let acknowledged = self
17221 .acknowledged_operations(Some(&changed_rows), None)
17222 .map_err(|error| ("storage.failed".to_owned(), error))?;
17223 self.apply_outbox_ops(&acknowledged)
17224 .map_err(|message| ("storage.failed".into(), message))?;
17225 self.apply_outbox_ops(
17226 self.outbox
17227 .iter()
17228 .flat_map(|commit| &commit.ops)
17229 .filter(|op| {
17230 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
17231 }),
17232 )
17233 .map_err(|message| ("storage.failed".into(), message))?;
17234 }
17235 self.overlay_dirty.set(false);
17236 }
17237 Ok(())
17238 }
17239
17240 fn apply_segment(
17245 &mut self,
17246 sub_index: usize,
17247 segment: &RowsSegment,
17248 first_fresh_page: bool,
17249 ) -> Result<u32, SectionError> {
17250 let (sub_table, effective) = {
17251 let sub = &self.subs[sub_index];
17252 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17253 };
17254 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
17255 SectionError::Abort(
17256 "sync.schema_mismatch".to_owned(),
17257 format!("subscription table {sub_table:?} is not in the client schema"),
17258 )
17259 })?;
17260 let matches = segment.table == table.name
17265 && segment.schema_version == self.schema.version
17266 && segment.columns.len() == table.wire_columns.len()
17267 && segment
17268 .columns
17269 .iter()
17270 .zip(table.wire_columns.iter())
17271 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
17272 if !matches {
17273 return Err(SectionError::Abort(
17274 "sync.schema_mismatch".to_owned(),
17275 "segment column table does not match the generated schema (§5.2)".to_owned(),
17276 ));
17277 }
17278 let mut applied = 0u32;
17279 let empty = Vec::new();
17280 let blocks = if segment.blocks.is_empty() {
17281 std::slice::from_ref(&empty)
17282 } else {
17283 &segment.blocks
17284 };
17285 for (index, block) in blocks.iter().enumerate() {
17286 let clear = first_fresh_page && index == 0;
17287 if !clear && block.is_empty() {
17288 continue;
17289 }
17290 let was_dirty = self.overlay_dirty.snapshot();
17291 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
17292 && self
17293 .outbox
17294 .iter()
17295 .flat_map(|commit| &commit.ops)
17296 .any(|op| op.table == table.name);
17297 let mirror_visible = !reconcile_pending
17298 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
17299 self.begin_observation("syncular_segment_block")
17300 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17301 let outcome = (|| {
17302 self.rebuild_overlay_if_dirty()
17303 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17304 let mut changed_keys = Vec::new();
17305 let mut batch = ChangeAccumulator::default();
17306 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
17307 batch.table(&table.name);
17308 }
17309 if clear {
17310 self.purge_scope_rows(&table.name, &effective)
17311 .map_err(|()| SectionError::FailClosed)?;
17312 }
17313 for (row_index, row) in block.iter().enumerate() {
17314 let decrypted;
17315 let values = if table.has_encrypted_columns() {
17316 let mut values = row.values.clone();
17317 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
17318 .map_err(|message| {
17319 SectionError::Abort("client.decrypt_failed".into(), message)
17320 })?;
17321 decrypted = values;
17322 &decrypted
17323 } else {
17324 &row.values
17325 };
17326 self.write_base_row(&table.name, values, row.server_version)
17327 .map_err(|message| {
17328 SectionError::Abort("sync.invalid_request".into(), message)
17329 })?;
17330 if reconcile_pending {
17331 changed_keys.push(
17332 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
17333 |message| SectionError::Abort("storage.failed".into(), message),
17334 )?,
17335 );
17336 }
17337 if mirror_visible {
17338 self.write_row(
17339 &visible_table(&table.name),
17340 &table.name,
17341 values,
17342 row.server_version,
17343 )
17344 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17345 }
17346 let processed = u64::from(applied) + row_index as u64 + 1;
17347 if processed.is_multiple_of(1024) {
17348 self.progress.update(|p| p.rows_processed = processed);
17349 }
17350 }
17351 if reconcile_pending {
17352 self.reconcile_imported_rows(&table, &mut changed_keys)
17353 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17354 }
17355 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
17356 self.apply_outbox_ops(
17357 self.outbox
17358 .iter()
17359 .flat_map(|commit| &commit.ops)
17360 .filter(|op| op.table == table.name),
17361 )
17362 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17363 self.overlay_dirty.set(false);
17364 } else {
17365 self.rebuild_overlay_if_dirty()
17366 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17367 }
17368 self.finish_observation("syncular_segment_block", batch)
17369 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
17370 })();
17371 if let Err(error) = outcome {
17372 self.rollback_observation("syncular_segment_block");
17373 self.overlay_dirty.restore(was_dirty.clone());
17374 return Err(error);
17375 }
17376 applied += block.len() as u32;
17377 self.progress
17378 .update(|p| p.rows_processed = u64::from(applied));
17379 std::thread::yield_now();
17380 }
17381 self.progress
17382 .update(|p| p.rows_processed = u64::from(applied));
17383 Ok(applied)
17384 }
17385
17386 fn apply_sqlite_segment(
17394 &mut self,
17395 sub_index: usize,
17396 bytes: &[u8],
17397 first_fresh_page: bool,
17398 row_count: i64,
17399 as_of_commit_seq: i64,
17400 scope_digest: &str,
17401 ) -> Result<u32, SectionError> {
17402 let invalid = |detail: &str| {
17403 SectionError::Abort(
17404 "sync.invalid_request".to_owned(),
17405 format!("sqlite segment rejected: {detail} (§5.3)"),
17406 )
17407 };
17408 let (sub_table, effective) = {
17409 let sub = &self.subs[sub_index];
17410 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17411 };
17412 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
17413 SectionError::Abort(
17414 "sync.schema_mismatch".to_owned(),
17415 format!("subscription table {sub_table:?} is not in the client schema"),
17416 )
17417 })?;
17418
17419 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
17420 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
17421 let img = match rusqlite::Connection::open_with_flags(
17422 &path,
17423 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
17424 ) {
17425 Ok(conn) => conn,
17426 Err(_) => {
17427 let _ = std::fs::remove_file(&path);
17428 return Err(invalid("bytes do not open as a SQLite database"));
17429 }
17430 };
17431 let outcome = self.apply_sqlite_image(
17432 &img,
17433 &table,
17434 first_fresh_page,
17435 &effective,
17436 row_count,
17437 as_of_commit_seq,
17438 scope_digest,
17439 );
17440 drop(img);
17441 let _ = std::fs::remove_file(&path);
17442 outcome
17443 }
17444
17445 #[allow(clippy::too_many_arguments)]
17446 fn apply_sqlite_image(
17447 &mut self,
17448 img: &rusqlite::Connection,
17449 table: &crate::schema::TableSchema,
17450 first_fresh_page: bool,
17451 effective: &[(String, Vec<String>)],
17452 row_count: i64,
17453 as_of_commit_seq: i64,
17454 scope_digest: &str,
17455 ) -> Result<u32, SectionError> {
17456 let invalid = |detail: String| {
17457 SectionError::Abort(
17458 "sync.invalid_request".to_owned(),
17459 format!("sqlite segment rejected: {detail} (§5.3)"),
17460 )
17461 };
17462
17463 type MetaRow = (i64, String, i64, i64, String, i64, i64);
17466 let meta: MetaRow = img
17467 .query_row(
17468 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
17469 \"scopeDigest\", \"rowCount\",
17470 (SELECT count(*) FROM _syncular_segment)
17471 FROM _syncular_segment",
17472 [],
17473 |row| {
17474 Ok((
17475 row.get(0)?,
17476 row.get(1)?,
17477 row.get(2)?,
17478 row.get(3)?,
17479 row.get(4)?,
17480 row.get(5)?,
17481 row.get(6)?,
17482 ))
17483 },
17484 )
17485 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
17486 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
17487 if meta_count != 1 {
17488 return Err(invalid(format!(
17489 "_syncular_segment must contain exactly one row, found {meta_count}"
17490 )));
17491 }
17492 if format != 1 {
17493 return Err(invalid(format!("format {format}")));
17494 }
17495 if meta_table != table.name {
17496 return Err(invalid(format!("image table {meta_table:?}")));
17497 }
17498 if schema_version != i64::from(self.schema.version) {
17499 return Err(invalid(format!("schemaVersion {schema_version}")));
17500 }
17501 if pin != as_of_commit_seq {
17502 return Err(invalid(format!("asOfCommitSeq {pin}")));
17503 }
17504 if digest != scope_digest {
17505 return Err(invalid("scopeDigest mismatch".to_owned()));
17506 }
17507 if meta_rows != row_count {
17508 return Err(invalid(format!("rowCount {meta_rows}")));
17509 }
17510
17511 let mut names: Vec<String> = Vec::new();
17513 let mut primary_keys: Vec<String> = Vec::new();
17514 {
17515 let mut stmt = img
17516 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
17517 .map_err(|_| invalid("image data table missing".to_owned()))?;
17518 let mut rows = stmt
17519 .query([])
17520 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
17521 while let Some(row) = rows
17522 .next()
17523 .map_err(|_| invalid("image data table unreadable".to_owned()))?
17524 {
17525 if row
17526 .get::<_, i64>(5)
17527 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
17528 > 0
17529 {
17530 primary_keys.push(row.get(1).map_err(|error| {
17531 invalid(Self::sqlite_failure(&self.storage_failure, error))
17532 })?);
17533 }
17534 names.push(
17535 row.get::<_, String>(1)
17536 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
17537 );
17538 }
17539 }
17540 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
17541 expected.push("_syncular_version");
17542 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
17543 return Err(SectionError::Abort(
17544 "sync.schema_mismatch".to_owned(),
17545 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
17546 ));
17547 }
17548
17549 if primary_keys != vec![table.primary_key.clone()] {
17550 return Err(invalid(
17551 "image primary key does not match generated schema".into(),
17552 ));
17553 }
17554 let actual_count: i64 = img
17555 .query_row(
17556 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
17557 [],
17558 |row| row.get(0),
17559 )
17560 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17561 if actual_count != row_count {
17562 return Err(invalid("image row count does not match descriptor".into()));
17563 }
17564 let insert = self.insert_row_sql(&base_table(&table.name), table);
17565 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
17566 let column_list = names
17567 .iter()
17568 .map(|n| quote_ident(n))
17569 .collect::<Vec<_>>()
17570 .join(", ");
17571 let mut stmt = img
17572 .prepare(&format!(
17573 "SELECT {column_list} FROM {} ORDER BY {}",
17574 quote_ident(&table.name),
17575 quote_ident(&table.primary_key)
17576 ))
17577 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17578 let mut rows = stmt
17579 .query([])
17580 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17581 let mut applied = 0u32;
17582 loop {
17583 let clear = applied == 0 && first_fresh_page;
17584 let was_dirty = self.overlay_dirty.snapshot();
17585 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
17586 && self
17587 .outbox
17588 .iter()
17589 .flat_map(|commit| &commit.ops)
17590 .any(|op| op.table == table.name);
17591 let mirror_visible = !reconcile_pending
17592 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
17593 self.begin_observation("syncular_image_chunk")
17594 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17595 let result = (|| {
17596 self.rebuild_overlay_if_dirty()
17597 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17598 let mut changed_keys = Vec::new();
17599 let mut batch = ChangeAccumulator::default();
17600 if clear {
17601 if self.scoped_rows_exist(&table.name, effective) {
17602 batch.table(&table.name);
17603 }
17604 self.purge_scope_rows(&table.name, effective)
17605 .map_err(|()| SectionError::FailClosed)?;
17606 }
17607 let mut processed = 0u32;
17608 {
17609 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
17610 invalid(Self::sqlite_failure(&self.storage_failure, error))
17611 })?;
17612 let mut mirror = if mirror_visible {
17613 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
17614 invalid(Self::sqlite_failure(&self.storage_failure, error))
17615 })?)
17616 } else {
17617 None
17618 };
17619 for _ in 0..1024 {
17620 let Some(row) = rows.next().map_err(|error| {
17621 invalid(Self::sqlite_failure(&self.storage_failure, error))
17622 })?
17623 else {
17624 break;
17625 };
17626 for (i, column) in table.columns.iter().enumerate() {
17627 let cell = row.get_ref(i).map_err(|error| {
17628 invalid(Self::sqlite_failure(&self.storage_failure, error))
17629 })?;
17630 let param = image_cell_param(column, cell).map_err(invalid)?;
17631 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
17632 invalid(Self::sqlite_failure(&self.storage_failure, error))
17633 })?;
17634 if let Some(mirror) = &mut mirror {
17635 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
17636 invalid(Self::sqlite_failure(&self.storage_failure, error))
17637 })?;
17638 }
17639 }
17640 let version_index = table.columns.len();
17641 let version: i64 = row.get(version_index).map_err(|error| {
17642 invalid(Self::sqlite_failure(&self.storage_failure, error))
17643 })?;
17644 if version < 1 {
17645 return Err(invalid("image row version must be positive".into()));
17646 }
17647 ins.raw_bind_parameter(version_index + 1, version)
17648 .map_err(|error| {
17649 invalid(Self::sqlite_failure(&self.storage_failure, error))
17650 })?;
17651 ins.raw_execute().map_err(|error| {
17652 invalid(Self::sqlite_failure(&self.storage_failure, error))
17653 })?;
17654 if let Some(mirror) = &mut mirror {
17655 mirror
17656 .raw_bind_parameter(version_index + 1, version)
17657 .map_err(|error| {
17658 invalid(Self::sqlite_failure(&self.storage_failure, error))
17659 })?;
17660 mirror.raw_execute().map_err(|error| {
17661 invalid(Self::sqlite_failure(&self.storage_failure, error))
17662 })?;
17663 }
17664 if reconcile_pending {
17665 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
17666 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
17667 )?);
17668 }
17669 processed += 1;
17670 }
17671 }
17672 if processed > 0 {
17673 batch.table(&table.name);
17674 self.overlay_dirty.table(&table.name);
17675 }
17676 if reconcile_pending {
17677 self.reconcile_imported_rows(table, &mut changed_keys)
17678 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17679 }
17680 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
17681 self.apply_outbox_ops(
17682 self.outbox
17683 .iter()
17684 .flat_map(|commit| &commit.ops)
17685 .filter(|op| op.table == table.name),
17686 )
17687 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17688 self.overlay_dirty.set(false);
17689 } else {
17690 self.rebuild_overlay_if_dirty()
17691 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17692 }
17693 self.finish_observation("syncular_image_chunk", batch)
17694 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17695 Ok(processed)
17696 })();
17697 let processed = match result {
17698 Ok(processed) => processed,
17699 Err(error) => {
17700 self.rollback_observation("syncular_image_chunk");
17701 self.overlay_dirty.restore(was_dirty.clone());
17702 return Err(error);
17703 }
17704 };
17705 applied += processed;
17706 self.progress
17707 .update(|p| p.rows_processed = u64::from(applied));
17708 std::thread::yield_now();
17709 if i64::from(applied) == actual_count {
17710 break;
17711 }
17712 }
17713 Ok(applied)
17714 }
17715
17716 fn reconcile_imported_rows(
17720 &self,
17721 table: &TableSchema,
17722 keys: &mut Vec<SqlValue>,
17723 ) -> Result<(), String> {
17724 let visible = visible_table(&table.name);
17725 let base = base_table(&table.name);
17726 let pk = quote_ident(&table.primary_key);
17727 let predicate = row_id_predicate(table);
17728 let mut lookup = self.conn.prepare_cached(&format!(
17729 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
17730 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17731 let mut deleted = HashSet::new();
17732 for op in self
17733 .outbox
17734 .iter()
17735 .flat_map(|commit| &commit.ops)
17736 .filter(|op| op.table == table.name)
17737 {
17738 if op.upsert {
17739 if let Some(values) = &op.values {
17740 let key = json_to_column_value(
17741 &table.columns[table.pk_index],
17742 values.get(&table.primary_key),
17743 )?;
17744 keys.push(owned_sql_value(RowParam::Cell(&key))?);
17745 }
17746 } else if deleted.insert(&op.row_id) {
17747 keys.extend(
17748 lookup
17749 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
17750 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
17751 .collect::<Result<Vec<_>, _>>()
17752 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
17753 );
17754 }
17755 }
17756 for chunk in keys.chunks(400) {
17759 let holes = vec!["?"; chunk.len()].join(",");
17760 self.conn
17761 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
17762 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
17763 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17764 }
17765 let updates = table
17766 .columns
17767 .iter()
17768 .map(|column| column.name.as_str())
17769 .chain(std::iter::once("_syncular_version"))
17770 .filter(|name| *name != table.primary_key)
17771 .map(|name| {
17772 let name = quote_ident(name);
17773 format!("{name}=excluded.{name}")
17774 })
17775 .collect::<Vec<_>>()
17776 .join(",");
17777 for chunk in keys.chunks(400) {
17778 let holes = vec!["?"; chunk.len()].join(",");
17779 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
17780 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
17781 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17782 }
17783 Ok(())
17784 }
17785
17786 fn compile_local_data_purge(
17789 &self,
17790 input: &LocalDataPurgeInput,
17791 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
17792 let invalid = |message: String| format!("sync.invalid_request: {message}");
17793 if input.purge_id.is_empty()
17794 || input.purge_id.len() > 128
17795 || !is_local_operation_code_like(&input.purge_id)
17796 {
17797 return Err(invalid(
17798 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
17799 ));
17800 }
17801 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
17802 return Err(invalid(format!(
17803 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
17804 )));
17805 }
17806
17807 let mut deduplicated: BTreeMap<
17808 String,
17809 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
17810 > = BTreeMap::new();
17811 for target in &input.targets {
17812 let table = self.schema.table(&target.table).ok_or_else(|| {
17813 invalid(format!(
17814 "local purge names unknown table {:?}",
17815 target.table
17816 ))
17817 })?;
17818 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
17819 return Err(invalid(format!(
17820 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
17821 target.table
17822 )));
17823 }
17824 let mut selectors = Vec::with_capacity(target.selectors.len());
17825 let mut canonical_selectors = BTreeMap::new();
17826 for (column_name, raw_values) in &target.selectors {
17827 let Some((column_index, column)) = table
17828 .columns
17829 .iter()
17830 .enumerate()
17831 .find(|(_, column)| column.name == *column_name)
17832 else {
17833 return Err(invalid(format!(
17834 "local purge target {:?} names unknown column {:?}",
17835 target.table, column_name
17836 )));
17837 };
17838 let encrypted = table
17839 .encrypted_columns
17840 .iter()
17841 .any(|candidate| candidate.index == column_index);
17842 if column.ty != ColumnType::String || encrypted {
17843 return Err(invalid(format!(
17844 "local purge selector {:?}.{:?} must be a plaintext string column",
17845 target.table, column_name
17846 )));
17847 }
17848 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
17849 return Err(invalid(format!(
17850 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
17851 target.table, column_name
17852 )));
17853 }
17854 let mut values = raw_values.clone();
17855 values.sort();
17856 values.dedup();
17857 if values.iter().any(|value| {
17858 value.is_empty()
17859 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
17860 || !is_local_operation_code_like(value)
17861 }) {
17862 return Err(invalid(format!(
17863 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
17864 )));
17865 }
17866 selectors.push((column_name.clone(), values.clone()));
17867 canonical_selectors.insert(column_name.clone(), values);
17868 }
17869 let canonical = LocalDataPurgeTarget {
17870 table: target.table.clone(),
17871 selectors: canonical_selectors,
17872 };
17873 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
17874 deduplicated.insert(
17875 key,
17876 (
17877 CompiledLocalDataPurgeTarget {
17878 table: target.table.clone(),
17879 selectors,
17880 },
17881 canonical,
17882 ),
17883 );
17884 }
17885 let targets = deduplicated
17886 .values()
17887 .map(|(compiled, _)| compiled.clone())
17888 .collect::<Vec<_>>();
17889 let canonical = deduplicated
17890 .values()
17891 .map(|(_, target)| target.clone())
17892 .collect::<Vec<_>>();
17893 let canonical_plan =
17894 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
17895 Ok((targets, canonical_plan))
17896 }
17897
17898 fn local_purge_base_row_ids(
17899 &self,
17900 targets: &[CompiledLocalDataPurgeTarget],
17901 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
17902 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
17903 for target in targets {
17904 let table = self
17905 .schema
17906 .table(&target.table)
17907 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
17908 let mut clauses = Vec::with_capacity(target.selectors.len());
17909 let mut params = Vec::new();
17910 for (column, values) in &target.selectors {
17911 clauses.push(format!(
17912 "{} IN ({})",
17913 quote_ident(column),
17914 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
17915 ));
17916 params.extend(values.iter().cloned().map(SqlValue::Text));
17917 }
17918 let sql = format!(
17919 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
17920 quote_ident(&table.primary_key),
17921 base_table(&target.table),
17922 clauses.join(" AND ")
17923 );
17924 let mut statement = self
17925 .conn
17926 .prepare(&sql)
17927 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17928 let rows = statement
17929 .query_map(rusqlite::params_from_iter(params), |row| {
17930 let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
17931 render_row_id_json(Some(&value))
17932 .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
17933 })
17934 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17935 let ids = by_table.entry(target.table.clone()).or_default();
17936 for row in rows {
17937 ids.insert(
17938 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
17939 );
17940 }
17941 }
17942 Ok(by_table)
17943 }
17944
17945 fn local_purge_values_match(
17946 target: &CompiledLocalDataPurgeTarget,
17947 values: &Map<String, Value>,
17948 ) -> bool {
17949 target.selectors.iter().all(|(column, allowed)| {
17950 values
17951 .get(column)
17952 .and_then(Value::as_str)
17953 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
17954 })
17955 }
17956
17957 pub fn purge_local_data(
17961 &mut self,
17962 input: &LocalDataPurgeInput,
17963 ) -> Result<LocalDataPurgeResult, String> {
17964 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
17965 let meta_key = format!("localPurge:{}", input.purge_id);
17966 let applied_plan = self
17967 .conn
17968 .query_row(
17969 "SELECT value FROM _syncular_meta WHERE key = ?1",
17970 rusqlite::params![meta_key],
17971 |row| row.get::<_, String>(0),
17972 )
17973 .optional()
17974 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17975 if let Some(applied_plan) = applied_plan {
17976 if applied_plan != canonical_plan {
17977 return Err(format!(
17978 "sync.invalid_request: local purge id {:?} was already used with a different plan",
17979 input.purge_id
17980 ));
17981 }
17982 return Ok(LocalDataPurgeResult {
17983 already_applied: true,
17984 purged_rows: 0,
17985 dropped_commits: 0,
17986 });
17987 }
17988
17989 let _ = self.drop_previous_version();
17993
17994 let prior_outbox = self.outbox.clone();
17995 let prior_failed = self.failed_commits.clone();
17996 let prior_rejections = self.rejections.clone();
17997 let prior_conflicts = self.conflicts.clone();
17998 let prior_overlay_dirty = self.overlay_dirty.snapshot();
17999 self.begin_observation("syncular_local_purge")?;
18000 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
18001 let row_ids = self.local_purge_base_row_ids(&targets)?;
18002 let retained_dropped = self.drop_failed_commits(|table, values| {
18003 targets.iter().any(|target| {
18004 target.table == table && Self::local_purge_values_match(target, values)
18005 })
18006 })?;
18007 let doomed = self
18008 .outbox
18009 .iter()
18010 .filter(|commit| {
18011 commit.ops.iter().any(|operation| {
18012 let matching_targets = targets
18013 .iter()
18014 .filter(|target| target.table == operation.table)
18015 .collect::<Vec<_>>();
18016 if matching_targets.is_empty() {
18017 return false;
18018 }
18019 if row_ids
18020 .get(&operation.table)
18021 .is_some_and(|ids| ids.contains(&operation.row_id))
18022 {
18023 return true;
18024 }
18025 operation.values.as_ref().is_some_and(|values| {
18026 matching_targets
18027 .iter()
18028 .any(|target| Self::local_purge_values_match(target, values))
18029 })
18030 })
18031 })
18032 .cloned()
18033 .collect::<Vec<_>>();
18034 let doomed_ids = doomed
18035 .iter()
18036 .map(|commit| commit.client_commit_id.clone())
18037 .collect::<BTreeSet<_>>();
18038 let mut batch = ChangeAccumulator::default();
18039 for commit in &retained_dropped {
18040 batch.conflicts = true;
18041 batch.rejections = true;
18042 batch.outcomes = true;
18043 for operation in &commit.ops {
18044 batch.table(&operation.table);
18045 }
18046 }
18047 let mut rejections = Vec::new();
18048 for commit in &doomed {
18049 for operation in &commit.ops {
18050 batch.table(&operation.table);
18051 }
18052 let results = commit
18053 .ops
18054 .iter()
18055 .enumerate()
18056 .map(|(op_index, operation)| {
18057 let rejection = RejectionRecord {
18058 client_commit_id: commit.client_commit_id.clone(),
18059 op_index: op_index as i32,
18060 code: "client.local_data_purged".to_owned(),
18061 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
18062 retryable: false,
18063 details: None,
18064 operation: Some(CommitOperation::from(operation)),
18065 };
18066 rejections.push(rejection.clone());
18067 CommitOperationOutcome::Error { rejection }
18068 })
18069 .collect::<Vec<_>>();
18070 self.persist_commit_outcome(
18071 &commit.client_commit_id,
18072 CommitOutcomeStatus::Rejected,
18073 &results,
18074 Some(&commit.ops),
18075 )?;
18076 self.delete_outbox_persisted(&commit.client_commit_id)?;
18077 }
18078 if !doomed.is_empty() {
18079 self.outbox
18080 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
18081 self.rejections.extend(rejections);
18082 self.prune_commit_outcomes()?;
18083 batch.status = true;
18084 batch.rejections = true;
18085 batch.outcomes = true;
18086 }
18087
18088 let mut purged_rows = 0usize;
18089 for (table, ids) in &row_ids {
18090 if ids.is_empty() {
18091 continue;
18092 }
18093 batch.table(table);
18094 for row_id in ids {
18095 self.delete_base_row(table, row_id)?;
18096 purged_rows += 1;
18097 }
18098 }
18099 self.rebuild_overlay_if_dirty()?;
18100 self.delete_unreferenced_cached_blobs()?;
18101 self.conn
18102 .execute(
18103 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
18104 rusqlite::params![meta_key, canonical_plan],
18105 )
18106 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18107 Ok((
18108 batch,
18109 LocalDataPurgeResult {
18110 already_applied: false,
18111 purged_rows,
18112 dropped_commits: doomed.len(),
18113 },
18114 ))
18115 })();
18116 let (batch, result) = match applied {
18117 Ok(value) => value,
18118 Err(error) => {
18119 self.rollback_observation("syncular_local_purge");
18120 self.outbox = prior_outbox;
18121 self.failed_commits = prior_failed;
18122 self.rejections = prior_rejections;
18123 self.conflicts = prior_conflicts;
18124 self.overlay_dirty.restore(prior_overlay_dirty.clone());
18125 return Err(error);
18126 }
18127 };
18128 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
18129 self.rollback_observation("syncular_local_purge");
18130 self.outbox = prior_outbox;
18131 self.failed_commits = prior_failed;
18132 self.rejections = prior_rejections;
18133 self.conflicts = prior_conflicts;
18134 self.overlay_dirty.restore(prior_overlay_dirty.clone());
18135 return Err(error);
18136 }
18137 self.cancel_sync_round();
18138 Ok(result)
18139 }
18140
18141 pub fn rebootstrap_local_data(
18147 &mut self,
18148 input: &LocalDataRebootstrapInput,
18149 ) -> Result<LocalDataRebootstrapResult, String> {
18150 if input.rebootstrap_id.is_empty()
18151 || input.rebootstrap_id.len() > 128
18152 || !is_local_operation_code_like(&input.rebootstrap_id)
18153 {
18154 return Err(
18155 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
18156 .to_owned(),
18157 );
18158 }
18159 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
18160 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
18161 let (retained_commits, reset_subscriptions) =
18162 decode_local_rebootstrap_receipt(&persisted)?;
18163 return Ok(LocalDataRebootstrapResult {
18164 already_applied: true,
18165 retained_commits,
18166 reset_subscriptions,
18167 });
18168 }
18169 if self.stopped || self.schema_floor.is_some() {
18170 return Err(
18171 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
18172 .to_owned(),
18173 );
18174 }
18175
18176 let retained_commits = self.outbox.len();
18177 let reset_subscriptions = self.subs.len();
18178 let prior_subs = self.subs.clone();
18179 let prior_upgrading = self.upgrading;
18180 let prior_stopped = self.stopped;
18181 let prior_schema_floor = self.schema_floor.clone();
18182 let prior_overlay_dirty = self.overlay_dirty.snapshot();
18183 let prior_sync_needed = self.sync_needed;
18184 let prior_sync_intents = self.sync_intent_queue.clone();
18185 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
18186
18187 let prior_active_round = self.active_round;
18188 self.begin_observation("syncular_local_rebootstrap")?;
18189 let mut batch = ChangeAccumulator::default();
18190 let applied = (|| -> Result<(), String> {
18191 self.run_schema_reset_observed(&mut batch, false)?;
18192 self.conn
18193 .execute(
18194 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
18195 rusqlite::params![meta_key, receipt],
18196 )
18197 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18198 self.sync_needed = true;
18199 self.sync_intent_queue.push_back(SyncIntent::Interactive);
18200 batch.status = true;
18201 Ok(())
18202 })();
18203 if let Err(error) = applied {
18204 self.rollback_observation("syncular_local_rebootstrap");
18205 self.active_round = prior_active_round;
18206 self.subs = prior_subs;
18207 self.upgrading = prior_upgrading;
18208 self.stopped = prior_stopped;
18209 self.schema_floor = prior_schema_floor;
18210 self.overlay_dirty.restore(prior_overlay_dirty.clone());
18211 self.sync_needed = prior_sync_needed;
18212 self.sync_intent_queue = prior_sync_intents;
18213 return Err(error);
18214 }
18215 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
18216 self.rollback_observation("syncular_local_rebootstrap");
18217 self.active_round = prior_active_round;
18218 self.subs = prior_subs;
18219 self.upgrading = prior_upgrading;
18220 self.stopped = prior_stopped;
18221 self.schema_floor = prior_schema_floor;
18222 self.overlay_dirty.restore(prior_overlay_dirty.clone());
18223 self.sync_needed = prior_sync_needed;
18224 self.sync_intent_queue = prior_sync_intents;
18225 return Err(error);
18226 }
18227
18228 Ok(LocalDataRebootstrapResult {
18229 already_applied: false,
18230 retained_commits,
18231 reset_subscriptions,
18232 })
18233 }
18234
18235 fn purge_scope_rows(
18240 &mut self,
18241 table_name: &str,
18242 effective: &[(String, Vec<String>)],
18243 ) -> Result<(), ()> {
18244 if effective.is_empty() {
18245 return Ok(());
18246 }
18247 let table = self.schema.table(table_name).ok_or(())?.clone();
18248 let mut clauses = Vec::new();
18249 let mut params: Vec<SqlValue> = Vec::new();
18250 for (variable, values) in effective {
18251 let column = table.scope_column(variable).ok_or(())?;
18252 let placeholders: Vec<String> = values
18253 .iter()
18254 .map(|v| {
18255 params.push(SqlValue::Text(v.clone()));
18256 "?".to_owned()
18257 })
18258 .collect();
18259 clauses.push(format!(
18260 "{} IN ({})",
18261 quote_ident(column),
18262 placeholders.join(", ")
18263 ));
18264 }
18265 let sql = format!(
18266 "DELETE FROM {} WHERE {}",
18267 base_table(table_name),
18268 clauses.join(" AND ")
18269 );
18270 self.overlay_dirty.table(table_name);
18271 self.conn
18272 .execute(&sql, rusqlite::params_from_iter(¶ms))
18273 .map_err(|_| ())?;
18274 self.conn
18275 .execute(
18276 &format!(
18277 "DELETE FROM {} WHERE {}",
18278 visible_table(table_name),
18279 clauses.join(" AND ")
18280 ),
18281 rusqlite::params_from_iter(¶ms),
18282 )
18283 .map_err(|_| ())?;
18284 Ok(())
18285 }
18286
18287 fn drop_doomed_outbox(
18290 &mut self,
18291 table_name: &str,
18292 effective: &[(String, Vec<String>)],
18293 ) -> Result<bool, String> {
18294 if effective.is_empty() {
18295 return Ok(false);
18296 }
18297 let Some(table) = self.schema.table(table_name).cloned() else {
18298 return Ok(false);
18299 };
18300 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
18301 for (variable, values) in effective {
18302 match table.scope_column(variable) {
18303 Some(column) => mappings.push((column, values)),
18304 None => return Ok(false), }
18306 }
18307 let doomed: Vec<OutboxCommit> = self
18308 .outbox
18309 .iter()
18310 .filter(|commit| {
18311 commit.ops.iter().any(|op| {
18312 op.upsert
18313 && op.table == table_name
18314 && op.values.as_ref().is_some_and(|values| {
18315 mappings
18316 .iter()
18317 .all(|(column, allowed)| match values.get(*column) {
18318 Some(Value::String(s)) => allowed.contains(s),
18319 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
18320 Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
18321 _ => false,
18322 })
18323 })
18324 })
18325 })
18326 .cloned()
18327 .collect();
18328 if doomed.is_empty() {
18329 return Ok(false);
18330 }
18331 let mut rejections = Vec::new();
18332 for commit in &doomed {
18333 let results = commit
18334 .ops
18335 .iter()
18336 .enumerate()
18337 .map(|(op_index, operation)| {
18338 let rejection = RejectionRecord {
18339 client_commit_id: commit.client_commit_id.clone(),
18340 op_index: op_index as i32,
18341 code: "sync.scope_revoked".to_owned(),
18342 message: "the commit was dropped because its effective scope was revoked"
18343 .to_owned(),
18344 retryable: false,
18345 details: None,
18346 operation: Some(CommitOperation::from(operation)),
18347 };
18348 rejections.push(rejection.clone());
18349 CommitOperationOutcome::Error { rejection }
18350 })
18351 .collect::<Vec<_>>();
18352 self.persist_commit_outcome(
18353 &commit.client_commit_id,
18354 CommitOutcomeStatus::Rejected,
18355 &results,
18356 Some(&commit.ops),
18357 )?;
18358 self.delete_outbox_persisted(&commit.client_commit_id)?;
18359 }
18360 self.prune_commit_outcomes()?;
18361 let doomed_ids = doomed
18362 .iter()
18363 .map(|commit| commit.client_commit_id.as_str())
18364 .collect::<BTreeSet<_>>();
18365 self.outbox
18366 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
18367 self.rejections.extend(rejections);
18368 Ok(true)
18369 }
18370
18371 pub fn upload_blob(
18378 &mut self,
18379 bytes: &[u8],
18380 media_type: Option<String>,
18381 name: Option<String>,
18382 ) -> Result<Value, String> {
18383 let blob_id = blob_id_for(bytes);
18384 let now = self.clock_now_ms();
18385 let tx = self
18386 .conn
18387 .transaction()
18388 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18389 tx.execute(
18390 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
18391 ON CONFLICT(blob_id) DO NOTHING",
18392 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
18393 )
18394 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18395 tx.execute(
18396 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
18397 rusqlite::params![blob_id, media_type, now],
18398 )
18399 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18400 tx.commit()
18401 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18402 self.enforce_blob_cache_cap();
18403 let mut obj = Map::new();
18404 obj.insert("blobId".to_owned(), Value::from(blob_id));
18405 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
18406 if let Some(mt) = media_type {
18407 obj.insert("mediaType".to_owned(), Value::from(mt));
18408 }
18409 if let Some(n) = name {
18410 obj.insert("name".to_owned(), Value::from(n));
18411 }
18412 Ok(Value::Object(obj))
18413 }
18414
18415 pub fn fetch_blob_bytes(
18419 &mut self,
18420 transport: &mut dyn Transport,
18421 blob_id_or_ref: &str,
18422 ) -> Result<FetchedBlob, (String, String)> {
18423 let simple = |m: String| ("client.failed".to_owned(), m);
18424 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
18425 blob_id_or_ref.to_owned()
18426 } else {
18427 let value: Value = serde_json::from_str(blob_id_or_ref)
18428 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
18429 value
18430 .get("blobId")
18431 .and_then(Value::as_str)
18432 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
18433 .to_owned()
18434 };
18435 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
18436 return Ok(cached);
18437 }
18438 if !self.transport_enabled {
18439 return Err(("sync.offline".into(), "transport gate is closed".into()));
18440 }
18441 #[cfg(feature = "bench-internals")]
18447 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
18448 let bytes = match transport
18449 .blob_download(&blob_id)
18450 .map_err(|e| (e.code, e.message))?
18451 {
18452 BlobDownload::Bytes(bytes) => bytes,
18453 BlobDownload::Url {
18454 url,
18455 url_expires_at_ms,
18456 } => {
18457 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
18459 return Err((
18460 "sync.segment_expired".to_owned(),
18461 format!(
18462 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
18463 ),
18464 ));
18465 }
18466 transport
18467 .fetch_blob_url(&url)
18468 .map_err(|e| (e.code, e.message))?
18469 }
18470 };
18471 #[cfg(feature = "bench-internals")]
18472 drop(download_phase);
18473 #[cfg(feature = "bench-internals")]
18474 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
18475 if blob_id_for(&bytes) != blob_id {
18477 return Err(simple(format!(
18478 "blob content address mismatch for {blob_id}"
18479 )));
18480 }
18481 #[cfg(feature = "bench-internals")]
18482 drop(validate_phase);
18483 #[cfg(feature = "bench-internals")]
18484 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
18485 let now = self.clock_now_ms();
18486 self.conn
18487 .execute(
18488 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
18489 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
18490 )
18491 .map_err(|e| simple(e.to_string()))?;
18492 self.conn
18493 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
18494 .map_err(|e| simple(e.to_string()))?;
18495 self.enforce_blob_cache_cap();
18496 #[cfg(feature = "bench-internals")]
18497 drop(insert_phase);
18498 self.get_cached_blob_bytes(&blob_id)
18499 .map_err(simple)?
18500 .ok_or_else(|| simple("blob cache write failed".to_owned()))
18501 }
18502
18503 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
18504 #[cfg(feature = "bench-internals")]
18505 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
18506 let mut stmt = self
18507 .conn
18508 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
18509 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18510 let mut rows = stmt
18511 .query(rusqlite::params![blob_id])
18512 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18513 if let Some(row) = rows
18514 .next()
18515 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
18516 {
18517 let bytes: Vec<u8> = row
18518 .get(0)
18519 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18520 let byte_length: i64 = row
18521 .get(1)
18522 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18523 let media_type: Option<String> = row
18524 .get(2)
18525 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18526 return Ok(Some(FetchedBlob {
18527 blob_id: blob_id.to_owned(),
18528 byte_length,
18529 bytes,
18530 media_type,
18531 }));
18532 }
18533 Ok(None)
18534 }
18535
18536 fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
18538 let pending: Vec<(String, Option<String>)> = {
18539 let mut stmt = self
18540 .conn
18541 .prepare(
18542 "SELECT blob_id, media_type FROM (
18543 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
18544 UNION ALL
18545 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
18546 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
18547 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
18548 ) ORDER BY created_at_ms, blob_id",
18549 )
18550 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18551 let rows = stmt
18552 .query_map([], |row| {
18553 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
18554 })
18555 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18556 rows.collect::<Result<_, _>>()
18557 .map_err(|error| match error {
18558 rusqlite::Error::InvalidColumnType(..)
18559 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
18560 "sync.local_corrupt",
18561 "Pending blob upload metadata is invalid",
18562 ),
18563 _ => TransportError::new("client.failed", error.to_string()),
18564 })?
18565 };
18566 let mut uploads = Vec::new();
18567 for (blob_id, media_type) in pending {
18568 let bytes = (|| {
18569 let stored: Option<(SqlValue, SqlValue)> = self
18570 .conn
18571 .query_row(
18572 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
18573 rusqlite::params![blob_id],
18574 |row| Ok((row.get(0)?, row.get(1)?)),
18575 )
18576 .optional()
18577 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18578 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
18579 return Err(TransportError::new(
18580 "sync.local_corrupt",
18581 "Pending blob upload body is missing or corrupt",
18582 ));
18583 };
18584 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
18585 return Err(TransportError::new(
18586 "sync.local_corrupt",
18587 "Pending blob upload body is missing or corrupt",
18588 ));
18589 }
18590 Ok(bytes)
18591 })();
18592 let failed = bytes.is_err();
18593 uploads.push(crate::round::PendingUpload {
18594 id: blob_id,
18595 bytes,
18596 media_type,
18597 });
18598 if failed {
18599 break;
18600 }
18601 }
18602 Ok(uploads)
18603 }
18604
18605 #[cfg(test)]
18606 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
18607 for upload in self.pending_blob_uploads()? {
18608 self.upload_one(
18609 transport,
18610 &upload.id,
18611 &upload.bytes?,
18612 upload.media_type.as_deref(),
18613 )?;
18614 self.conn
18615 .execute(
18616 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
18617 rusqlite::params![upload.id],
18618 )
18619 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18620 }
18621 Ok(())
18622 }
18623
18624 #[cfg(test)]
18631 fn upload_one(
18632 &self,
18633 transport: &mut dyn Transport,
18634 blob_id: &str,
18635 bytes: &[u8],
18636 media_type: Option<&str>,
18637 ) -> Result<(), TransportError> {
18638 crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
18639 }
18640
18641 fn visible_blob_ids_sql(&self) -> String {
18642 let mut selects = Vec::new();
18643 for table in &self.schema.tables {
18644 for column in table
18645 .columns
18646 .iter()
18647 .filter(|column| column.ty == ColumnType::BlobRef)
18648 {
18649 let identifier = quote_ident(&column.name);
18650 selects.push(format!(
18651 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
18652 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
18653 AND json_type({identifier}, '$.blobId') = 'text'",
18654 quote_ident(&table.name),
18655 ));
18656 }
18657 }
18658 if selects.is_empty() {
18659 "SELECT NULL AS blob_id WHERE 0".to_owned()
18660 } else {
18661 selects.join(" UNION ")
18662 }
18663 }
18664
18665 fn enforce_blob_cache_cap(&self) {
18668 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
18669 return;
18670 };
18671 let mut total: i64 = self
18672 .conn
18673 .query_row(
18674 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
18675 [],
18676 |row| row.get(0),
18677 )
18678 .unwrap_or(0);
18679 if total <= max_bytes {
18680 return;
18681 }
18682 let query = format!(
18683 "SELECT blob_id, byte_length FROM _syncular_blobs
18684 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
18685 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
18686 AND blob_id NOT IN ({})
18687 ORDER BY created_at_ms ASC, blob_id ASC",
18688 self.visible_blob_ids_sql(),
18689 );
18690 let candidates: Vec<(String, i64)> = {
18691 let Ok(mut stmt) = self.conn.prepare(&query) else {
18692 return;
18693 };
18694 let Ok(rows) = stmt.query_map([], |row| {
18695 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
18696 }) else {
18697 return;
18698 };
18699 rows.filter_map(Result::ok).collect()
18700 };
18701 for (blob_id, byte_length) in candidates {
18702 if total <= max_bytes {
18703 break;
18704 }
18705 let _ = self.conn.execute(
18706 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
18707 rusqlite::params![blob_id],
18708 );
18709 total -= byte_length;
18710 }
18711 }
18712
18713 fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
18714 #[cfg(feature = "bench-internals")]
18715 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
18716 if !self.schema_has_blobs() {
18717 return Ok(());
18718 }
18719 self.rebuild_overlay_if_dirty()?;
18720 self.conn
18721 .execute(
18722 &format!(
18723 "DELETE FROM _syncular_blobs
18724 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
18725 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
18726 AND blob_id NOT IN ({})",
18727 self.visible_blob_ids_sql(),
18728 ),
18729 [],
18730 )
18731 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18732 Ok(())
18733 }
18734
18735 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
18740 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
18741 return sql.clone();
18742 }
18743 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
18744 columns.push(quote_ident("_syncular_version"));
18745 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
18746 let primary_key = quote_ident(&table.primary_key);
18747 let updates = columns
18748 .iter()
18749 .filter(|column| **column != primary_key)
18750 .map(|column| format!("{column}=excluded.{column}"))
18751 .collect::<Vec<_>>()
18752 .join(", ");
18753 let sql = format!(
18754 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
18755 columns.join(", "),
18756 placeholders.join(", ")
18757 );
18758 self.insert_sql
18759 .borrow_mut()
18760 .insert(full_table.to_owned(), sql.clone());
18761 sql
18762 }
18763
18764 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
18765 self.overlay_dirty.table(table_name);
18766 self.write_row(&base_table(table_name), table_name, row, version)
18767 }
18768
18769 fn write_row(
18770 &self,
18771 full_table: &str,
18772 table_name: &str,
18773 row: &Row,
18774 version: i64,
18775 ) -> Result<(), String> {
18776 #[cfg(feature = "bench-internals")]
18777 let _phase = self.benchmark_phases.start(Phase::RowWrite);
18778 let table = self
18779 .schema
18780 .table(table_name)
18781 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
18782 let sql = self.insert_row_sql(full_table, table);
18783 let mut stmt = self
18784 .conn
18785 .prepare_cached(&sql)
18786 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18787 let params = row
18788 .iter()
18789 .map(RowParam::Cell)
18790 .chain(std::iter::once(RowParam::Version(version)));
18791 stmt.execute(rusqlite::params_from_iter(params))
18792 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18793 Ok(())
18794 }
18795
18796 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
18797 let table = self
18798 .schema
18799 .table(table_name)
18800 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
18801 self.overlay_dirty.table(table_name);
18802 let sql = format!(
18803 "DELETE FROM {} WHERE {}",
18804 base_table(table_name),
18805 row_id_predicate(table)
18806 );
18807 let mut stmt = self
18808 .conn
18809 .prepare_cached(&sql)
18810 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18811 stmt.execute(rusqlite::params![row_id])
18812 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18813 Ok(())
18814 }
18815
18816 fn has_acknowledged_rows(&self) -> bool {
18817 self.conn
18818 .query_row(
18819 "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
18820 [],
18821 |row| row.get(0),
18822 )
18823 .expect("read acknowledged intent")
18824 }
18825
18826 fn acknowledged_operations(
18827 &self,
18828 rows: Option<&HashSet<(&str, &str)>>,
18829 tables: Option<&BTreeSet<&str>>,
18830 ) -> Result<Vec<OutboxOp>, String> {
18831 let keys = rows.map(|rows| {
18832 serde_json::to_string(
18833 &rows
18834 .iter()
18835 .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
18836 .collect::<Vec<_>>(),
18837 )
18838 .expect("row keys")
18839 });
18840 let mut statement = self
18841 .conn
18842 .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")
18843 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18844 let rows = statement
18845 .query_map(
18846 rusqlite::params![
18847 keys,
18848 tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
18849 ],
18850 |row| {
18851 #[cfg(test)]
18852 self.acknowledged_replay_count
18853 .set(self.acknowledged_replay_count.get() + 1);
18854 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
18855 },
18856 )
18857 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18858 rows.map(|row| {
18859 let (op, intent) =
18860 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18861 let op: CommitOperation =
18862 serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
18863 let table = self
18864 .schema
18865 .table(&op.table)
18866 .ok_or_else(|| "sync.unknown_table".to_owned())?;
18867 let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
18868 Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
18869 } else {
18870 op.values
18871 };
18872 Ok(OutboxOp {
18873 table: op.table,
18874 row_id: op.row_id,
18875 upsert: op.op == "upsert",
18876 base_version: op.base_version,
18877 values,
18878 })
18879 })
18880 .collect()
18881 }
18882
18883 fn drop_acknowledged_rows(
18884 &self,
18885 matches: impl Fn(&str, &Map<String, Value>) -> bool,
18886 ) -> Result<Vec<OutboxCommit>, String> {
18887 let mut statement = self
18888 .conn
18889 .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
18890 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18891 let rows = statement
18892 .query_map([], |row| {
18893 Ok((
18894 row.get::<_, String>(0)?,
18895 row.get::<_, String>(1)?,
18896 row.get::<_, String>(2)?,
18897 row.get::<_, String>(3)?,
18898 ))
18899 })
18900 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18901 let mut doomed = HashSet::new();
18902 for row in rows {
18903 let (id, table, intent, row_id) =
18904 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18905 let values =
18906 serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
18907 let base_matches = self
18908 .schema
18909 .table(&table)
18910 .map(|schema| self.stored_row(schema, &row_id, true))
18911 .transpose()?
18912 .flatten()
18913 .is_some_and(|(base, _)| {
18914 let schema = self.schema.table(&table).expect("acknowledged table");
18915 let values = schema
18916 .columns
18917 .iter()
18918 .zip(base.iter())
18919 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
18920 .collect();
18921 matches(&table, &values)
18922 });
18923 if matches(&table, &values) || base_matches {
18924 doomed.insert(id);
18925 }
18926 }
18927 let mut dropped = Vec::new();
18928 for id in doomed {
18929 let mut statement = self
18930 .conn
18931 .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
18932 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18933 let operations = statement
18934 .query_map([&id], |row| row.get::<_, String>(0))
18935 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18936 let mut ops = Vec::new();
18937 for operation in operations {
18938 let json = operation
18939 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18940 let op: CommitOperation =
18941 serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
18942 self.overlay_dirty.table(&op.table);
18943 ops.push(OutboxOp {
18944 table: op.table,
18945 row_id: op.row_id,
18946 upsert: op.op == "upsert",
18947 base_version: op.base_version,
18948 values: op.values,
18949 });
18950 }
18951 dropped.push(OutboxCommit {
18952 client_commit_id: id.clone(),
18953 ops,
18954 });
18955 self.conn
18956 .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
18957 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18958 }
18959 Ok(dropped)
18960 }
18961
18962 fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
18965 if self.overlay_dirty.get() {
18966 self.rebuild_overlay()?;
18967 }
18968 Ok(())
18969 }
18970
18971 fn rebuild_overlay(&mut self) -> Result<(), String> {
18975 #[cfg(feature = "bench-internals")]
18976 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
18977 #[cfg(test)]
18978 self.overlay_rebuild_count
18979 .set(self.overlay_rebuild_count.get() + 1);
18980 let dirty = self.overlay_dirty.snapshot();
18981 let tables: Vec<_> = self
18982 .schema
18983 .tables
18984 .iter()
18985 .filter(|table| {
18986 dirty
18987 .as_ref()
18988 .is_none_or(|names| names.is_empty() || names.contains(&table.name))
18989 })
18990 .cloned()
18991 .collect();
18992 let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
18993 self.begin_observation("syncular_overlay")?;
18994 let rebuilt = (|| {
18995 for table in &tables {
18996 for index in &table.fts_indexes {
18997 self.drop_fts_triggers(index)?;
18998 }
18999 let visible = visible_table(&table.name);
19000 let base = base_table(&table.name);
19001 self.conn
19002 .execute_batch(&format!(
19003 "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
19004 ))
19005 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19006 }
19007 let acknowledged = self.acknowledged_operations(None, Some(&names))?;
19008 self.apply_outbox_ops(
19009 acknowledged
19010 .iter()
19011 .filter(|op| names.contains(op.table.as_str())),
19012 )?;
19013 for (commit, initial) in &self.failed_commits {
19014 for operation in &commit.ops {
19015 if !names.contains(operation.table.as_str()) || !operation.upsert {
19016 continue;
19017 }
19018 if let Some(table) = self.schema.table(&operation.table) {
19019 if self.visible_row(table, &operation.row_id)?.is_none()
19020 && operation.values.as_ref().is_some_and(|values| {
19021 table
19022 .columns
19023 .iter()
19024 .all(|column| values.contains_key(&column.name))
19025 })
19026 {
19027 let key = serde_json::to_string(&(
19028 operation.table.clone(),
19029 operation.row_id.clone(),
19030 ))
19031 .expect("row key serialization");
19032 if let Some(Value::Object(values)) = initial.get(&key) {
19033 self.apply_outbox_ops(&[OutboxOp {
19034 values: Some(values.clone()),
19035 ..operation.clone()
19036 }])?;
19037 }
19038 }
19039 }
19040 }
19041 self.apply_outbox_ops(
19042 commit
19043 .ops
19044 .iter()
19045 .filter(|op| names.contains(op.table.as_str())),
19046 )?;
19047 }
19048 for commit in &self.outbox {
19049 self.apply_outbox_ops(
19050 commit
19051 .ops
19052 .iter()
19053 .filter(|op| names.contains(op.table.as_str())),
19054 )?;
19055 }
19056 for table in &tables {
19057 for index in &table.fts_indexes {
19058 self.rebuild_fts_projection(table, index)?;
19059 self.create_fts_triggers(table, index)?;
19060 }
19061 }
19062 self.conn
19063 .execute_batch("RELEASE syncular_overlay")
19064 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19065 Ok(())
19066 })();
19067 if rebuilt.is_err() {
19068 self.conn
19069 .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
19070 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19071 } else {
19072 self.overlay_dirty.set(false);
19073 }
19074 rebuilt
19075 }
19076
19077 fn apply_outbox_ops<'a>(
19082 &self,
19083 ops: impl IntoIterator<Item = &'a OutboxOp>,
19084 ) -> Result<(), String> {
19085 #[cfg(feature = "bench-internals")]
19086 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
19087 for op in ops {
19088 #[cfg(feature = "bench-internals")]
19089 if let Some(phase) = &mut phase {
19090 phase.unit();
19091 }
19092 if op.upsert {
19093 if let (Some(table), Some(values)) = (
19094 self.schema
19095 .table(&op.table)
19096 .filter(|table| table.indexes.iter().any(|index| index.unique)),
19097 op.values.as_ref(),
19098 ) {
19099 let mut intended: Map<String, Value> = self
19100 .visible_row(table, &op.row_id)?
19101 .map(|(row, _)| {
19102 table
19103 .columns
19104 .iter()
19105 .zip(&row)
19106 .map(|(column, value)| {
19107 (column.name.clone(), column_value_to_json(value))
19108 })
19109 .collect()
19110 })
19111 .unwrap_or_default();
19112 intended.extend(values.clone());
19113 if !self.unique_conflicts(table, &intended, false)?.is_empty() {
19114 continue;
19115 }
19116 }
19117 }
19118 self.apply_outbox_op(op)?;
19119 }
19120 Ok(())
19121 }
19122
19123 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
19124 let Some(table) = self.schema.table(&op.table) else {
19125 return Ok(());
19126 };
19127 let visible = visible_table(&table.name);
19128 if !op.upsert {
19129 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
19130 self.conn
19131 .prepare_cached(&sql)
19132 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
19133 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19134 return Ok(());
19135 }
19136 let Some(values) = op.values.as_ref() else {
19137 return Ok(());
19138 };
19139 let full = table
19144 .columns
19145 .iter()
19146 .all(|column| values.contains_key(&column.name));
19147 let local = self.visible_row(table, &op.row_id)?;
19148 if local.is_none() && !full {
19149 return Ok(());
19150 }
19151 let mut row: Row = Vec::with_capacity(table.columns.len());
19152 for (index, column) in table.columns.iter().enumerate() {
19153 let incoming = match values.get(&column.name) {
19154 Some(raw) => json_to_column_value(
19155 if raw.get("$bytes").is_some()
19156 && table
19157 .encrypted_columns
19158 .iter()
19159 .any(|encrypted| encrypted.index == index)
19160 {
19161 &table.wire_columns[index]
19162 } else {
19163 column
19164 },
19165 Some(raw),
19166 ),
19167 None => Ok(local
19168 .as_ref()
19169 .and_then(|(values, _)| values.get(index).cloned())
19170 .flatten()),
19171 };
19172 row.push(incoming.map_err(|_| {
19173 "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
19174 })?);
19175 }
19176 let version = local.as_ref().map_or(-1, |(_, version)| *version);
19177 self.write_row(&visible, &table.name, &row, version)
19178 .map_err(|error| {
19179 if error.starts_with("UNIQUE constraint failed:") {
19180 "sync.constraint_violation: local write violates a unique constraint".to_owned()
19181 } else {
19182 error
19183 }
19184 })
19185 }
19186
19187 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
19190 self.stored_row(table, row_id, false)
19191 }
19192
19193 fn stored_row(
19194 &self,
19195 table: &TableSchema,
19196 row_id: &str,
19197 base: bool,
19198 ) -> Result<Option<(Row, i64)>, String> {
19199 let sql = format!(
19200 "SELECT * FROM {} WHERE {}",
19201 if base {
19202 base_table(&table.name)
19203 } else {
19204 visible_table(&table.name)
19205 },
19206 row_id_predicate(table)
19207 );
19208 let mut stmt = self
19209 .conn
19210 .prepare_cached(&sql)
19211 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19212 let mut rows = stmt
19213 .query(rusqlite::params![row_id])
19214 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19215 let Some(row) = rows
19216 .next()
19217 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19218 else {
19219 return Ok(None);
19220 };
19221 let mut values = Vec::with_capacity(table.columns.len());
19222 for (index, column) in table.columns.iter().enumerate() {
19223 let raw = row
19224 .get_ref(index)
19225 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19226 if let rusqlite::types::ValueRef::Blob(bytes) = raw {
19227 if table
19228 .encrypted_columns
19229 .iter()
19230 .any(|encrypted| encrypted.index == index)
19231 {
19232 values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
19233 continue;
19234 }
19235 }
19236 let json = sql_ref_to_json(column, raw);
19237 values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
19238 "sync.local_corrupt: persisted local row value is invalid".to_owned()
19239 })?);
19240 }
19241 let version: i64 = row
19242 .get(table.columns.len())
19243 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19244 Ok(Some((values, version)))
19245 }
19246
19247 #[must_use]
19251 pub fn realtime_policy(&self) -> RealtimePolicy {
19252 self.realtime_policy
19253 }
19254
19255 #[must_use]
19257 pub fn realtime_state(&self) -> RealtimeState {
19258 self.realtime_state
19259 }
19260
19261 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
19264 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
19265 return Err(
19266 "sync.invalid_request: disconnect realtime before changing the realtime policy"
19267 .to_owned(),
19268 );
19269 }
19270 self.realtime_policy = policy;
19271 match policy {
19272 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
19273 RealtimePolicy::Required | RealtimePolicy::Optional
19274 if self.realtime_state == RealtimeState::Disabled =>
19275 {
19276 self.set_realtime_state(RealtimeState::Disconnected, None);
19277 }
19278 RealtimePolicy::Required | RealtimePolicy::Optional => {}
19279 }
19280 Ok(())
19281 }
19282
19283 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
19286 self.realtime_state = state;
19287 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
19288 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
19289 self.realtime_reason_code = None;
19290 self.realtime_retry_delay_ms = None;
19291 }
19292 }
19293
19294 #[must_use]
19295 fn realtime_connected(&self) -> bool {
19296 self.realtime_state == RealtimeState::Connected
19297 }
19298
19299 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
19300 if !self.transport_enabled {
19301 return Err("sync.offline: transport gate is closed".into());
19302 }
19303 if self.realtime_policy == RealtimePolicy::Off {
19304 return Err(
19305 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
19306 );
19307 }
19308 if self.realtime_connected() {
19309 return Ok(());
19310 }
19311 self.realtime_state = RealtimeState::Connecting;
19312 match transport.realtime_connect_for_client(&self.client_id) {
19313 Ok(()) => {
19314 self.set_realtime_state(RealtimeState::Connected, None);
19315 Ok(())
19316 }
19317 Err(error) => {
19318 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
19319 Err(format!("{}: {}", error.code, error.message))
19320 }
19321 }
19322 }
19323
19324 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
19325 self.cancel_sync_round();
19326 if self.realtime_state == RealtimeState::Disconnected
19329 || self.realtime_state == RealtimeState::Disabled
19330 {
19331 return;
19332 }
19333 let _ = transport.realtime_close();
19334 self.set_realtime_state(
19335 if self.realtime_policy == RealtimePolicy::Off {
19336 RealtimeState::Disabled
19337 } else {
19338 RealtimeState::Disconnected
19339 },
19340 None,
19341 );
19342 self.presence.clear(); }
19344
19345 pub fn set_presence(
19351 &mut self,
19352 transport: &mut dyn Transport,
19353 scope_key: &str,
19354 doc: Option<&Value>,
19355 ) -> Result<(), String> {
19356 if !self.transport_enabled {
19357 return Err("sync.offline: transport gate is closed".into());
19358 }
19359 if !self.realtime_connected() {
19360 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
19361 }
19362 let text = encode_presence_publish(scope_key, doc);
19363 transport
19364 .realtime_send(&text)
19365 .map_err(|e| format!("{}: {}", e.code, e.message))
19366 }
19367
19368 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
19370 self.presence
19371 .get(scope_key)
19372 .map(|peers| peers.values().cloned().collect())
19373 .unwrap_or_default()
19374 }
19375
19376 fn apply_presence(
19378 &mut self,
19379 scope_key: String,
19380 kind: Option<PresenceKind>,
19381 actor_id: Option<String>,
19382 client_id: Option<String>,
19383 doc: Option<Value>,
19384 error: Option<String>,
19385 ) {
19386 if error.is_some() {
19389 return;
19390 }
19391 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
19392 return;
19393 };
19394 let peer_key = format!("{actor_id} {client_id}");
19395 match kind {
19396 PresenceKind::Leave => {
19397 if let Some(peers) = self.presence.get_mut(&scope_key) {
19398 peers.remove(&peer_key);
19399 if peers.is_empty() {
19400 self.presence.remove(&scope_key);
19401 }
19402 }
19403 }
19404 _ => {
19405 let doc = match doc {
19406 Some(Value::Object(_)) => doc.unwrap(),
19407 _ => return,
19408 };
19409 self.presence.entry(scope_key).or_default().insert(
19410 peer_key,
19411 PresencePeer {
19412 actor_id,
19413 client_id,
19414 doc,
19415 },
19416 );
19417 }
19418 }
19419 }
19420
19421 pub fn on_realtime_text(&mut self, text: &str) {
19423 if self.stopped || !self.transport_enabled || self.security_preflight {
19424 return;
19425 }
19426 match parse_control(text) {
19427 Ok(ControlMessage::Hello { requires_sync, .. }) => {
19428 if requires_sync {
19429 self.set_sync_needed(true, true);
19431 }
19432 }
19433 Ok(ControlMessage::Presence {
19434 scope_key,
19435 kind,
19436 actor_id,
19437 client_id,
19438 doc,
19439 error,
19440 ..
19441 }) => {
19442 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
19443 }
19444 Ok(ControlMessage::Wake { .. }) => {
19445 self.set_sync_needed(true, true);
19447 }
19448 _ => {}
19449 }
19450 }
19451
19452 pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
19456 let mut transport = crate::round::DownloadResults::default();
19457 self.on_realtime_binary(&mut transport, bytes);
19458 transport.controls
19459 }
19460
19461 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
19462 if self.stopped || !self.transport_enabled || self.security_preflight {
19463 return;
19464 }
19465 #[cfg(feature = "bench-internals")]
19466 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
19467 let message = match decode_message(bytes) {
19468 Ok(m) if m.msg_kind == MsgKind::Response => m,
19469 _ => {
19470 self.set_sync_needed(true, true);
19471 return;
19472 }
19473 };
19474 #[cfg(feature = "bench-internals")]
19475 drop(decode_phase);
19476 let mut frames = message.frames.into_iter();
19477 let mut applied_cursor: Option<i64> = None;
19478 let mut any_covered = false;
19479 let mut dropped = false;
19480 while let Some(frame) = frames.next() {
19481 let Frame::SubStart {
19482 id,
19483 status,
19484 effective_scopes,
19485 ..
19486 } = frame
19487 else {
19488 continue;
19489 };
19490 let mut body = Vec::new();
19491 let mut next_cursor: Option<i64> = None;
19492 for inner in frames.by_ref() {
19493 match inner {
19494 Frame::SubEnd {
19495 next_cursor: nc, ..
19496 } => {
19497 next_cursor = Some(nc);
19498 break;
19499 }
19500 Frame::Unknown { .. } => {}
19501 other => body.push(other),
19502 }
19503 }
19504 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
19505 dropped = true;
19506 continue;
19507 };
19508 let sub = &self.subs[sub_index];
19509 if status != SubStatus::Active
19512 || sub.state != SubState::Active
19513 || sub.bootstrap_state.is_some()
19514 || !sub.synced_once
19515 {
19516 dropped = true;
19517 continue;
19518 }
19519 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
19520 any_covered = true;
19522 continue;
19523 }
19524 let previous_effective = self.subs[sub_index].effective.clone();
19525 let previous_cursor = self.subs[sub_index].cursor;
19526 self.subs[sub_index].effective = Some(effective_scopes);
19527 let mut failed = false;
19528 for inner in body {
19529 match inner {
19530 Frame::Commit {
19531 tables,
19532 changes,
19533 commit_seq,
19534 ..
19535 } => {
19536 if self
19537 .apply_commit_frame(&tables, &changes, Some(commit_seq))
19538 .is_err()
19539 {
19540 failed = true;
19541 break;
19542 }
19543 }
19544 _ => {
19545 failed = true;
19546 break;
19547 }
19548 }
19549 }
19550 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
19551 self.apply_sub_end(sub_index, cursor, None)
19552 .ok()
19553 .map(|()| cursor)
19554 });
19555 let Some(next_cursor) = committed else {
19556 self.subs[sub_index].effective = previous_effective;
19557 self.subs[sub_index].cursor = previous_cursor;
19558 dropped = true;
19559 continue;
19560 };
19561 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
19562 }
19563 if let Some(cursor) = applied_cursor {
19564 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
19566 let _ = transport.realtime_send(&ack);
19567 } else if !any_covered || dropped {
19568 self.set_sync_needed(true, true);
19570 }
19571 }
19572
19573 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
19577 if !self.realtime_connected() {
19578 return;
19579 }
19580 let floor = self
19581 .subs
19582 .iter()
19583 .filter(|s| {
19584 s.state == SubState::Active
19585 && s.bootstrap_state.is_none()
19586 && s.synced_once
19587 && s.cursor >= 0
19588 })
19589 .map(|s| s.cursor)
19590 .min();
19591 if let Some(cursor) = floor {
19592 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
19593 let _ = transport.realtime_send(&ack);
19594 }
19595 }
19596}
19597
19598#[cfg(test)]
19599mod previous_version_wiring_tests {
19600 use super::*;
19601 use serde_json::{json, Value};
19602
19603 fn previous_version_schema(version: i32) -> Value {
19604 json!({
19605 "version": version,
19606 "tables": [{
19607 "name": "tasks",
19608 "primaryKey": "id",
19609 "columns": [
19610 { "name": "id", "type": "string", "nullable": false },
19611 { "name": "note", "type": "string", "nullable": true }
19612 ],
19613 "scopes": []
19614 }]
19615 })
19616 }
19617
19618 fn previous_version_limits() -> crate::api::ClientLimits {
19619 crate::api::ClientLimits {
19620 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
19621 enabled: true,
19622 ..Default::default()
19623 }),
19624 ..Default::default()
19625 }
19626 }
19627
19628 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
19630 previous_version_limits()
19631 .previous_version_context
19632 .expect("configured")
19633 }
19634
19635 fn recapture_previous_version(client: &SyncClient) {
19638 let replica_path = client
19639 .previous_version_replica_path()
19640 .expect("file-backed replica");
19641 let outcome = crate::previous_version::capture_previous_version_from_replica(
19642 &client.conn,
19643 &replica_path,
19644 2,
19645 2,
19646 &previous_version_config(),
19647 1,
19648 )
19649 .expect("capture");
19650 assert!(matches!(
19651 outcome,
19652 crate::previous_version::CaptureOutcome::Captured(_)
19653 ));
19654 }
19655
19656 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
19657 Subscription {
19658 id: "wiring-sub".to_owned(),
19659 table: "tasks".to_owned(),
19660 requested: Vec::new(),
19661 params: None,
19662 cursor,
19663 bootstrap_state: bootstrap_state.map(str::to_owned),
19664 state,
19665 reason_code: None,
19666 effective: None,
19667 synced_once: false,
19668 }
19669 }
19670
19671 #[test]
19672 fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
19673 let path =
19674 std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
19675 let path_str = path.to_str().expect("temp path");
19676 let schema = previous_version_schema(3);
19677 let mut older = previous_version_schema(2);
19678 older["tables"][0]["columns"] =
19679 json!([{ "name": "id", "type": "string", "nullable": false }]);
19680 let mut client = SyncClient::open_path(
19681 "downgrade".into(),
19682 &previous_version_schema(1),
19683 previous_version_limits(),
19684 path_str,
19685 )
19686 .expect("fresh");
19687 client
19688 .mutate(vec![Mutation::Upsert {
19689 table: "tasks".into(),
19690 values: Map::from_iter([
19691 ("id".into(), json!("accepted")),
19692 ("note".into(), json!("old")),
19693 ]),
19694 base_version: None,
19695 }])
19696 .expect("v1 write");
19697 client
19698 .recreate_with_schema(&schema)
19699 .expect("upgrade captures context");
19700 let commit = client
19701 .mutate(vec![Mutation::Upsert {
19702 table: "tasks".into(),
19703 values: Map::from_iter([
19704 ("id".into(), json!("queued")),
19705 ("note".into(), json!("v3-only")),
19706 ]),
19707 base_version: None,
19708 }])
19709 .expect("queued v3 write");
19710 let changes = client.conn.total_changes();
19711 let outbox = client.pending_commit_ids();
19712 assert!(outbox.contains(&commit));
19713 let descriptor = client.get_meta("localSchemaDescriptor");
19714 let container = format!(
19715 "{path_str}{}",
19716 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
19717 );
19718 let context = std::fs::read(&container).expect("captured context");
19719 assert!(client
19720 .recreate_with_schema(&older)
19721 .expect_err("refuse recreate")
19722 .starts_with("client.schema_downgrade:"));
19723 assert_eq!(client.schema.version, 3);
19724 assert_eq!(client.conn.total_changes(), changes);
19725 assert_eq!(client.pending_commit_ids(), outbox);
19726 assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
19727 assert_eq!(
19728 client
19729 .conn
19730 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
19731 row.get::<_, String>(0)
19732 })
19733 .expect("queued row"),
19734 "v3-only"
19735 );
19736 assert_eq!(std::fs::read(&container).expect("context remains"), context);
19737 drop(client);
19738 let before = std::fs::read(&path).expect("replica bytes");
19739 for entry in 0..3 {
19742 let error = match entry {
19743 0 => SyncClient::open_path(
19744 "downgrade".into(),
19745 &older,
19746 previous_version_limits(),
19747 path_str,
19748 ),
19749 1 => SyncClient::open_path_with_identity(
19750 None,
19751 &older,
19752 previous_version_limits(),
19753 path_str,
19754 ),
19755 _ => SyncClient::with_connection(
19756 "downgrade".into(),
19757 &older,
19758 previous_version_limits(),
19759 Connection::open(&path).expect("connection"),
19760 ),
19761 }
19762 .err()
19763 .expect("refuse open");
19764 assert!(error.starts_with("client.schema_downgrade:"));
19765 let conn = Connection::open(&path).expect("inspect");
19766 assert_eq!(
19767 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
19768 .expect("journal mode"),
19769 "delete"
19770 );
19771 drop(conn);
19772 assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
19773 assert_eq!(
19774 std::fs::read(&container).expect("context unchanged"),
19775 context
19776 );
19777 }
19778 let reopened = SyncClient::open_path(
19779 "downgrade".into(),
19780 &schema,
19781 previous_version_limits(),
19782 path_str,
19783 )
19784 .expect("compatible reopen");
19785 assert_eq!(reopened.pending_commit_ids(), outbox);
19786 assert_eq!(
19787 reopened
19788 .conn
19789 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
19790 row.get::<_, String>(0)
19791 })
19792 .expect("queued row remains"),
19793 "v3-only"
19794 );
19795 reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
19796 reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
19797 drop(reopened);
19798 let legacy = SyncClient::open_path(
19799 "downgrade".into(),
19800 &schema,
19801 previous_version_limits(),
19802 path_str,
19803 )
19804 .expect("legacy no-marker reopen");
19805 assert_eq!(legacy.pending_commit_ids(), outbox);
19806 assert!(!legacy.upgrading());
19807 assert_eq!(
19808 legacy
19809 .conn
19810 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
19811 row.get::<_, String>(0)
19812 })
19813 .expect("legacy row remains"),
19814 "v3-only"
19815 );
19816 drop(legacy);
19817 std::fs::remove_file(path).expect("remove replica");
19818 let _ = std::fs::remove_file(container);
19819 }
19820
19821 #[test]
19822 fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
19823 for marker in [
19824 "''",
19825 "'bad'",
19826 "'0'",
19827 "'-1'",
19828 "'2.5'",
19829 "'03'",
19830 "'3\n'",
19831 "' 3'",
19832 "'+3'",
19833 "'3e0'",
19834 "'2147483648'",
19835 "NULL",
19836 "X'33'",
19837 ] {
19838 let conn = Connection::open_in_memory().expect("connection");
19839 conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
19840 let error = SyncClient::with_connection(
19841 "marker".into(),
19842 &previous_version_schema(3),
19843 ClientLimits::default(),
19844 conn,
19845 )
19846 .err()
19847 .expect("refuse marker");
19848 assert!(
19849 error.starts_with("sync.local_corrupt:"),
19850 "{marker}: {error}"
19851 );
19852 }
19853 for fault in [
19854 "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
19855 "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
19856 ] {
19857 let mut client = SyncClient::new(
19858 "marker".into(),
19859 &previous_version_schema(3),
19860 ClientLimits::default(),
19861 )
19862 .expect("fresh");
19863 let commit = client
19864 .mutate(vec![Mutation::Upsert {
19865 table: "tasks".into(),
19866 values: Map::from_iter([("id".into(), json!("queued"))]),
19867 base_version: None,
19868 }])
19869 .expect("queued write");
19870 client.conn.execute_batch(fault).expect("marker fault");
19871 let changes = client.conn.total_changes();
19872 let error = client
19873 .recreate_with_schema(&previous_version_schema(4))
19874 .expect_err("refuse corrupt marker");
19875 assert!(error.starts_with("sync.local_corrupt:"));
19876 assert_eq!(client.conn.total_changes(), changes);
19877 assert_eq!(client.schema.version, 3);
19878 assert_eq!(client.pending_commit_ids(), vec![commit]);
19879 }
19880 for setup in [
19881 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
19882 "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
19883 ] {
19884 let path =
19885 std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
19886 let conn = Connection::open(&path).expect("connection");
19887 conn.execute_batch(setup)
19888 .expect("unreadable metadata fixture");
19889 drop(conn);
19890 let before = std::fs::read(&path).expect("before");
19891 let error = SyncClient::open_path_with_identity(
19892 None,
19893 &previous_version_schema(3),
19894 ClientLimits::default(),
19895 path.to_str().expect("path"),
19896 )
19897 .err()
19898 .expect("refuse unreadable marker");
19899 assert!(error.starts_with("sync.local_corrupt:"));
19900 assert_eq!(std::fs::read(&path).expect("after"), before);
19901 std::fs::remove_file(path).expect("remove fixture");
19902 }
19903 }
19904
19905 std::thread_local! {
19906 static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
19909 const { RefCell::new(None) };
19910 }
19911
19912 fn commit_concurrent_upgrade(attempts: i32) -> bool {
19913 CONCURRENT_UPGRADE.with(|slot| {
19914 if let Some(banner) = slot.borrow_mut().take() {
19915 banner
19916 .execute_batch(
19917 "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
19918 )
19919 .expect("commit the concurrent upgrade");
19920 }
19921 });
19922 attempts < 10
19923 }
19924
19925 fn journal_mode(conn: &Connection) -> String {
19926 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
19927 .expect("journal mode")
19928 }
19929
19930 fn temp_replica(label: &str) -> std::path::PathBuf {
19931 std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
19932 }
19933
19934 #[test]
19935 fn file_identity_constructor_requires_the_returned_wal_mode() {
19936 for path in [":memory:", ""] {
19937 let error = SyncClient::open_path_with_identity(
19938 None,
19939 &previous_version_schema(1),
19940 ClientLimits::default(),
19941 path,
19942 )
19943 .err()
19944 .expect("temporary SQLite databases cannot enter WAL mode");
19945 assert_eq!(
19946 error,
19947 "sync.invalid_request: file-backed client requires WAL journal mode"
19948 );
19949 }
19950 let path = temp_replica("confirmed-wal");
19951 let client = SyncClient::open_path_with_identity(
19952 None,
19953 &previous_version_schema(1),
19954 ClientLimits::default(),
19955 path.to_str().unwrap(),
19956 )
19957 .unwrap();
19958 assert_eq!(journal_mode(&client.conn), "wal");
19959 drop(client);
19960 std::fs::remove_file(path).unwrap();
19961 }
19962
19963 #[test]
19964 fn non_positive_generated_schema_versions_are_refused_before_storage() {
19965 for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
19966 let mut schema = previous_version_schema(1);
19967 schema["version"] = version.clone();
19968 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
19969 .err()
19970 .expect("refuse an out-of-range generated version");
19971 assert_eq!(
19972 error,
19973 "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
19974 );
19975 let path = temp_replica("bad-version");
19977 let path_str = path.to_str().expect("path");
19978 for identity in [false, true] {
19979 let error = if identity {
19980 SyncClient::open_path_with_identity(
19981 None,
19982 &schema,
19983 ClientLimits::default(),
19984 path_str,
19985 )
19986 } else {
19987 SyncClient::open_path(
19988 "bad-version".into(),
19989 &schema,
19990 ClientLimits::default(),
19991 path_str,
19992 )
19993 }
19994 .err()
19995 .expect("refuse before storage");
19996 assert!(
19997 error.starts_with("sync.invalid_request:"),
19998 "{version}: {error}"
19999 );
20000 assert!(
20001 !path.exists(),
20002 "{version}: a refused constructor creates no replica file"
20003 );
20004 }
20005 }
20006 let mut schema = previous_version_schema(1);
20008 schema
20009 .as_object_mut()
20010 .expect("schema object")
20011 .remove("version");
20012 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
20013 .err()
20014 .expect("refuse a missing version");
20015 assert!(error.starts_with("sync.invalid_request:"), "{error}");
20016 }
20017
20018 #[test]
20019 fn a_duplicated_schema_marker_is_refused_without_a_reset() {
20020 let path = temp_replica("duplicate-marker");
20021 let path_str = path.to_str().expect("path");
20022 {
20023 let conn = Connection::open(path_str).expect("fixture");
20026 conn.execute_batch(
20027 "CREATE TABLE _syncular_meta(key TEXT, value);
20028 INSERT INTO _syncular_meta VALUES
20029 ('localSchemaVersion', '1'),
20030 ('localSchemaVersion', '2'),
20031 ('clientId', 'duplicate');",
20032 )
20033 .expect("duplicate marker fixture");
20034 }
20035 let error = SyncClient::open_path_with_identity(
20036 None,
20037 &previous_version_schema(3),
20038 ClientLimits::default(),
20039 path_str,
20040 )
20041 .err()
20042 .expect("refuse a duplicated marker");
20043 assert!(error.starts_with("sync.local_corrupt:"), "{error}");
20044 let probe = Connection::open(path_str).expect("probe");
20045 assert_eq!(
20046 probe
20047 .query_row(
20048 "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
20049 [],
20050 |row| row.get::<_, i64>(0),
20051 )
20052 .expect("marker rows"),
20053 2,
20054 "the duplicated rows are left for the operator, not collapsed"
20055 );
20056 assert_eq!(
20057 probe
20058 .query_row(
20059 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
20060 [],
20061 |row| row.get::<_, i64>(0),
20062 )
20063 .expect("synced tables"),
20064 0,
20065 "a duplicated marker never drives the reset that recreates v3 tables"
20066 );
20067 assert_eq!(
20068 journal_mode(&probe),
20069 "delete",
20070 "the refusal leaves the journal mode alone"
20071 );
20072 drop(probe);
20073 std::fs::remove_file(path).expect("remove replica");
20074 }
20075
20076 #[test]
20077 fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
20078 let path = temp_replica("refused-wal");
20079 let path_str = path.to_str().expect("path");
20080 drop(
20083 SyncClient::open_path(
20084 "owner".into(),
20085 &previous_version_schema(2),
20086 ClientLimits::default(),
20087 path_str,
20088 )
20089 .expect("v2 replica"),
20090 );
20091 let before = std::fs::read(&path).expect("before");
20092 let error = SyncClient::open_path_with_identity(
20093 Some("intruder".into()),
20094 &previous_version_schema(2),
20095 ClientLimits::default(),
20096 path_str,
20097 )
20098 .err()
20099 .expect("refuse the identity mismatch");
20100 assert!(error.starts_with("client.identity_mismatch:"), "{error}");
20101 let probe = Connection::open(path_str).expect("probe");
20102 assert_eq!(
20103 journal_mode(&probe),
20104 "delete",
20105 "a refused open must not persist WAL"
20106 );
20107 assert_eq!(std::fs::read(&path).expect("after"), before);
20108 assert_eq!(
20109 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20110 Some("2")
20111 );
20112 drop(probe);
20113 std::fs::remove_file(path).expect("remove replica");
20114 }
20115
20116 #[test]
20117 fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
20118 let path = temp_replica("marker-race");
20119 let path_str = path.to_str().expect("path");
20120 drop(
20121 SyncClient::open_path_with_identity(
20122 Some("stale".into()),
20123 &previous_version_schema(1),
20124 ClientLimits::default(),
20125 path_str,
20126 )
20127 .expect("v1 replica"),
20128 );
20129 let banner = Connection::open(path_str).expect("banner");
20133 banner
20134 .execute_batch(
20135 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
20136 )
20137 .expect("pending upgrade");
20138 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
20139 let conn = Connection::open(path_str).expect("stale connection");
20140 conn.busy_handler(Some(commit_concurrent_upgrade))
20141 .expect("busy handler");
20142 let error = SyncClient::with_connection(
20143 "stale".into(),
20144 &previous_version_schema(1),
20145 ClientLimits::default(),
20146 conn,
20147 )
20148 .err()
20149 .expect("a concurrent upgrade must refuse the stale open");
20150 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
20151 let probe = Connection::open(path_str).expect("probe");
20152 assert_eq!(
20153 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20154 Some("2"),
20155 "the concurrent upgrade survives"
20156 );
20157 drop(probe);
20158 std::fs::remove_file(path).expect("remove replica");
20159 }
20160
20161 #[test]
20162 fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
20163 let path = temp_replica("recreate-race");
20164 let path_str = path.to_str().expect("path");
20165 let mut client = SyncClient::open_path_with_identity(
20166 Some("stale".into()),
20167 &previous_version_schema(1),
20168 ClientLimits::default(),
20169 path_str,
20170 )
20171 .expect("v1 replica");
20172 client
20173 .mutate(vec![Mutation::Upsert {
20174 table: "tasks".into(),
20175 values: Map::from_iter([("id".into(), json!("queued"))]),
20176 base_version: None,
20177 }])
20178 .expect("queued write");
20179 let commit = client.pending_commit_ids();
20180 let banner = Connection::open(path_str).expect("banner");
20181 banner
20182 .execute_batch(
20183 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
20184 )
20185 .expect("pending upgrade");
20186 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
20187 client
20188 .conn
20189 .busy_handler(Some(commit_concurrent_upgrade))
20190 .expect("busy handler");
20191 let error = client
20192 .recreate_with_schema(&previous_version_schema(1))
20193 .expect_err("a concurrent upgrade must refuse the stale recreate");
20194 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
20195 assert_eq!(client.schema.version, 1, "the compiled schema is restored");
20196 assert_eq!(
20197 client.pending_commit_ids(),
20198 commit,
20199 "the outbox is preserved"
20200 );
20201 let probe = Connection::open(path_str).expect("probe");
20202 assert_eq!(
20203 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20204 Some("2")
20205 );
20206 drop(probe);
20207 drop(client);
20208 std::fs::remove_file(path).expect("remove replica");
20209 }
20210
20211 #[test]
20212 fn failed_schema_record_writes_roll_back_recreation() {
20213 for key in [
20214 LOCAL_SCHEMA_VERSION_KEY,
20215 crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
20216 ] {
20217 let mut client = SyncClient::new(
20218 "schema-record".into(),
20219 &previous_version_schema(1),
20220 ClientLimits::default(),
20221 )
20222 .expect("v1");
20223 let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
20224 let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
20225 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");
20226 client
20227 .recreate_with_schema(&previous_version_schema(2))
20228 .expect_err("both record writes must propagate failure");
20229 assert_eq!(client.schema.version, 1);
20230 assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
20231 assert_eq!(
20232 client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
20233 descriptor
20234 );
20235 client
20236 .conn
20237 .execute_batch("DROP TRIGGER refuse_schema_record")
20238 .expect("remove fault");
20239 client
20240 .recreate_with_schema(&previous_version_schema(2))
20241 .expect("retry");
20242 assert_eq!(client.schema.version, 2);
20243 }
20244 }
20245
20246 #[test]
20247 fn a_failed_outer_commit_restores_every_reset_side_effect() {
20248 let path = temp_replica("outer-commit");
20249 let path_str = path.to_str().expect("path");
20250 let conn = Connection::open(path_str).expect("replica connection");
20253 let mut client = SyncClient::with_connection(
20254 "outer-commit".into(),
20255 &previous_version_schema(1),
20256 previous_version_limits(),
20257 conn,
20258 )
20259 .expect("v1 replica");
20260 assert_eq!(journal_mode(&client.conn), "delete");
20261 let commit = client
20262 .mutate(vec![Mutation::Upsert {
20263 table: "tasks".into(),
20264 values: Map::from_iter([
20265 ("id".into(), json!("queued")),
20266 ("note".into(), json!("dropped-by-v2")),
20267 ]),
20268 base_version: None,
20269 }])
20270 .expect("queued write");
20271 client.drain_change_batches();
20272 let mut dropped = previous_version_schema(2);
20274 dropped["tables"][0]["columns"] =
20275 json!([{ "name": "id", "type": "string", "nullable": false }]);
20276 let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
20279 let revision = client.local_revision();
20280 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
20281 let columns: i64 = client
20282 .conn
20283 .query_row(
20284 "SELECT count(*) FROM pragma_table_info('tasks')",
20285 [],
20286 |row| row.get(0),
20287 )
20288 .expect("column count");
20289 let rebuilds = client.overlay_rebuild_count.get();
20290 let prior_subs = client.subs.len();
20291 let prior_changes = client.change_queue.len();
20292 let prior_intents = client.sync_intent_queue.len();
20293 let prior_last_change = client.last_change.clone();
20294 let prior_overlay_dirty = client.overlay_dirty.get();
20295 let prior_sync_needed = client.sync_needed;
20296 let prior_upgrading = client.upgrading;
20297 let prior_stopped = client.stopped;
20298 let prior_schema_floor = client.schema_floor.clone();
20299 let prior_active_round = client.active_round;
20300 let reader = Connection::open(path_str).expect("reader");
20303 reader
20304 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
20305 .expect("reader snapshot");
20306 client
20307 .conn
20308 .busy_timeout(std::time::Duration::ZERO)
20309 .expect("busy timeout");
20310 let error = client
20311 .recreate_with_schema(&dropped)
20312 .expect_err("a blocked COMMIT must refuse the recreation");
20313 assert!(error.starts_with("client.storage_busy:"), "{error}");
20314 assert!(
20315 client.overlay_rebuild_count.get() > rebuilds,
20316 "the reset ran to its overlay rebuild inside the transaction"
20317 );
20318 assert!(client.conn.is_autocommit(), "the transaction is released");
20319 assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
20321 assert_eq!(client.local_revision(), revision);
20322 assert_eq!(
20323 client.query("SELECT * FROM tasks", &[]).expect("rows"),
20324 rows
20325 );
20326 assert_eq!(
20327 client
20328 .conn
20329 .query_row(
20330 "SELECT count(*) FROM pragma_table_info('tasks')",
20331 [],
20332 |row| { row.get::<_, i64>(0) }
20333 )
20334 .expect("column count"),
20335 columns,
20336 "the previous table layout survives the rollback"
20337 );
20338 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
20339 assert_eq!(client.schema.version, 1, "compiled schema");
20342 assert_eq!(client.subs.len(), prior_subs);
20343 assert_eq!(client.change_queue.len(), prior_changes);
20344 assert_eq!(client.sync_intent_queue.len(), prior_intents);
20345 assert_eq!(client.last_change, prior_last_change);
20346 assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
20347 assert_eq!(client.sync_needed, prior_sync_needed);
20348 assert_eq!(client.upgrading, prior_upgrading);
20349 assert_eq!(client.stopped, prior_stopped);
20350 assert_eq!(client.schema_floor, prior_schema_floor);
20351 assert_eq!(client.active_round, prior_active_round);
20352 assert!(
20353 client.rejections.is_empty(),
20354 "the dropped-column rejection is rolled back too"
20355 );
20356 let container = crate::previous_version::previous_version_container_path(path_str);
20359 assert!(std::path::Path::new(&container).exists());
20360 drop(reader);
20363 let snapshot = client
20364 .previous_version_snapshot(&PreviousVersionReadSpec {
20365 table: "tasks".into(),
20366 ..Default::default()
20367 })
20368 .expect("refuse stale capture");
20369 assert!(!snapshot.available);
20370 assert_eq!(snapshot.current_version, 1);
20371 assert!(!std::path::Path::new(&container).exists());
20372 client
20377 .conn
20378 .busy_timeout(std::time::Duration::from_secs(1))
20379 .expect("busy timeout");
20380 client
20381 .recreate_with_schema(&dropped)
20382 .expect("the recreation succeeds once the reader releases");
20383 assert_eq!(client.schema.version, 2);
20384 assert_eq!(
20385 client.pending_commit_ids(),
20386 vec![commit.clone()],
20387 "the reset kept the incompatible commit pending"
20388 );
20389 assert!(
20390 client.rejections.is_empty(),
20391 "no rejection exists before the send-time prepass"
20392 );
20393 client
20395 .prepare_sync_round(false)
20396 .expect("the send prepass runs during round preparation");
20397 assert!(client.pending_commit_ids().is_empty());
20398 assert!(
20399 client
20400 .rejections
20401 .iter()
20402 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
20403 "the dropped-column commit surfaced as a rejection at send time"
20404 );
20405 client.previous_version_discard();
20406 drop(client);
20407 std::fs::remove_file(path).expect("remove replica");
20408 }
20409
20410 #[test]
20411 fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
20412 let v1 = previous_version_schema(1);
20413 let mut v2 = previous_version_schema(2);
20414 v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
20416 let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
20417 let dropped = client
20418 .mutate(vec![Mutation::Upsert {
20419 table: "tasks".into(),
20420 values: Map::from_iter([
20421 ("id".into(), json!("dropped")),
20422 ("note".into(), json!("gone")),
20423 ]),
20424 base_version: None,
20425 }])
20426 .unwrap();
20427 let survivor = client
20431 .mutate(vec![Mutation::Delete {
20432 table: "tasks".into(),
20433 row_id: "gone".into(),
20434 base_version: None,
20435 }])
20436 .unwrap();
20437 client
20438 .recreate_with_schema(&v2)
20439 .expect("the reset preserves the outbox");
20440 assert_eq!(
20441 client.pending_commit_ids(),
20442 vec![dropped.clone(), survivor.clone()],
20443 "the reset preserves both commits"
20444 );
20445 assert!(client.rejections.is_empty(), "the reset classifies nothing");
20446 client.drain_change_batches();
20447 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
20448 assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
20449
20450 for fault in [
20456 "outcome-insert",
20457 "outbox-delete",
20458 "overlay-rebuild",
20459 "publish-revision",
20460 ] {
20461 client.set_sync_needed(false, false);
20462 client.drain_change_batches();
20463 let revision = client.local_revision();
20464 let trigger = match fault {
20465 "outcome-insert" => {
20466 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20467 }
20468 "outbox-delete" => {
20469 "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20470 }
20471 "publish-revision" => {
20476 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20477 }
20478 _ => {
20479 "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20480 }
20481 };
20482 client.conn.execute_batch(trigger).unwrap();
20483 let outcome = client
20484 .prepare_sync_round(false)
20485 .err()
20486 .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
20487 assert!(
20488 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
20489 "{fault}: {outcome:?}"
20490 );
20491 client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
20492 assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
20493 assert_eq!(
20494 client.pending_commit_ids(),
20495 vec![dropped.clone(), survivor.clone()],
20496 "{fault}: the outbox is restored"
20497 );
20498 assert!(
20499 client.rejections.is_empty(),
20500 "{fault}: no in-memory rejection survives"
20501 );
20502 assert!(
20503 client.commit_outcome(&dropped).unwrap().is_none(),
20504 "{fault}: the durable outcome rolled back"
20505 );
20506 assert_eq!(client.local_revision(), revision, "{fault}: revision");
20507 assert_eq!(
20508 client.query("SELECT * FROM tasks", &[]).unwrap(),
20509 rows,
20510 "{fault}: the visible projection is unchanged"
20511 );
20512 assert!(
20513 client.drain_change_batches().is_empty(),
20514 "{fault}: nothing is published"
20515 );
20516 }
20517
20518 client
20521 .conn
20522 .execute_batch(
20523 "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
20524 CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
20525 BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
20526 )
20527 .unwrap();
20528 assert!(client.drop_incompatible_outbox().is_err());
20529 assert!(!client.conn.is_autocommit());
20530 assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
20531 assert_eq!(
20532 client.pending_commit_ids(),
20533 vec![dropped.clone(), survivor.clone()]
20534 );
20535 assert!(client.commit_outcome(&dropped).unwrap().is_none());
20536 client
20537 .conn
20538 .execute_batch("DROP TRIGGER fail_drop; COMMIT")
20539 .unwrap();
20540
20541 client.set_meta(LOG_EPOCH_KEY, "epoch");
20546 client.set_sync_needed(false, false);
20547 client.drain_change_batches();
20548 let revision = client.local_revision();
20549 let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
20550 assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
20551 assert_eq!(
20552 prepared.meta.pushed_ids,
20553 vec![survivor.clone()],
20554 "the incompatible commit is classified before any operation is sent"
20555 );
20556 let rejection = client
20557 .rejections
20558 .iter()
20559 .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
20560 .expect("a sync.outbox_incompatible rejection");
20561 assert_eq!(rejection.client_commit_id, dropped);
20562 assert!(
20563 !rejection.retryable,
20564 "a schema-incompatible commit is final"
20565 );
20566 assert_eq!(
20567 client
20568 .commit_outcome(&dropped)
20569 .unwrap()
20570 .expect("a durable rejected outcome")
20571 .status,
20572 CommitOutcomeStatus::Rejected
20573 );
20574 assert_eq!(client.local_revision(), revision + 1, "one revision");
20575 let batches = client.drain_change_batches();
20576 assert_eq!(batches.len(), 1, "one change batch");
20577 assert!(
20578 batches[0].rejections_changed
20579 && batches[0].outcomes_changed
20580 && batches[0].status.is_some(),
20581 "{:?}",
20582 batches[0]
20583 );
20584 assert!(
20585 batches[0].tables.iter().any(|entry| entry.table == "tasks"),
20586 "{:?}",
20587 batches[0]
20588 );
20589 let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
20590 assert!(
20591 visible.is_empty(),
20592 "the dropped projection is undone: {visible:?}"
20593 );
20594
20595 let path = temp_replica("outbox-drop-commit");
20599 let path_str = path.to_str().expect("path");
20600 let conn = Connection::open(path_str).expect("replica connection");
20601 let mut client =
20602 SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
20603 .expect("v1 replica");
20604 let dropped = client
20605 .mutate(vec![Mutation::Upsert {
20606 table: "tasks".into(),
20607 values: Map::from_iter([
20608 ("id".into(), json!("dropped")),
20609 ("note".into(), json!("gone")),
20610 ]),
20611 base_version: None,
20612 }])
20613 .unwrap();
20614 client
20615 .recreate_with_schema(&v2)
20616 .expect("the reset preserves the outbox");
20617 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
20618 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
20619 assert_eq!(rows.len(), 1, "the optimistic row is visible");
20620 client.set_sync_needed(false, false);
20621 client.drain_change_batches();
20622 let revision = client.local_revision();
20623 let reader = Connection::open(path_str).expect("reader");
20624 reader
20625 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
20626 .expect("reader snapshot");
20627 client
20628 .conn
20629 .busy_timeout(std::time::Duration::ZERO)
20630 .expect("busy timeout");
20631 let outcome = client
20632 .prepare_sync_round(false)
20633 .err()
20634 .expect("a blocked COMMIT must fail the round");
20635 assert!(
20636 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
20637 "{outcome:?}"
20638 );
20639 assert!(client.conn.is_autocommit(), "the savepoint is released");
20640 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
20641 assert!(client.rejections.is_empty());
20642 assert!(client.commit_outcome(&dropped).unwrap().is_none());
20643 assert_eq!(client.local_revision(), revision);
20644 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
20645 assert!(client.drain_change_batches().is_empty());
20646 drop(reader);
20648 client
20649 .conn
20650 .busy_timeout(std::time::Duration::from_secs(1))
20651 .expect("busy timeout");
20652 client
20653 .prepare_sync_round(false)
20654 .expect("the drop succeeds after the reader releases");
20655 assert!(client.pending_commit_ids().is_empty());
20656 assert!(client
20657 .rejections
20658 .iter()
20659 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
20660 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
20661 drop(client);
20662 std::fs::remove_file(path).expect("remove replica");
20663 }
20664
20665 #[test]
20666 fn previous_version_read_reasons_come_from_live_client_state() {
20667 use crate::previous_version::{
20668 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
20669 };
20670 let path = std::env::temp_dir()
20671 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
20672 .to_string_lossy()
20673 .into_owned();
20674 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
20675 {
20676 let mut setup = SyncClient::open_path(
20677 "wiring".to_owned(),
20678 &previous_version_schema(1),
20679 previous_version_limits(),
20680 &path,
20681 )
20682 .expect("v1 install");
20683 setup
20684 .mutate(vec![Mutation::Upsert {
20685 table: "tasks".to_owned(),
20686 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
20687 base_version: None,
20688 }])
20689 .expect("seed a row");
20690 }
20691 let mut client = SyncClient::open_path(
20692 "wiring".to_owned(),
20693 &previous_version_schema(2),
20694 previous_version_limits(),
20695 &path,
20696 )
20697 .expect("v2 bump captures");
20698 assert!(std::path::Path::new(&container).exists());
20699 let spec = PreviousVersionReadSpec {
20700 table: "tasks".to_owned(),
20701 ..Default::default()
20702 };
20703
20704 recapture_previous_version(&client);
20706 client.lease_state = Some(LeaseState {
20707 lease_id: None,
20708 expires_at_ms: None,
20709 error_code: Some("lease.expired".to_owned()),
20710 });
20711 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20712 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
20713 assert!(!snapshot.available);
20714 assert!(!std::path::Path::new(&container).exists());
20715
20716 recapture_previous_version(&client);
20718 client.set_now_ms(1_000);
20719 client.lease_state = Some(LeaseState {
20720 lease_id: Some("lease".to_owned()),
20721 expires_at_ms: Some(1_000),
20722 error_code: None,
20723 });
20724 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20725 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
20726 assert!(!std::path::Path::new(&container).exists());
20727 client.lease_state = None;
20728
20729 recapture_previous_version(&client);
20731 client.subs.push(subscription(SubState::Revoked, 0, None));
20732 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20733 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
20734 assert!(!std::path::Path::new(&container).exists());
20735 client.subs.clear();
20736
20737 recapture_previous_version(&client);
20740 client
20741 .subs
20742 .push(subscription(SubState::Active, 0, Some("resume")));
20743 assert!(!client.previous_version_coverage_complete());
20744 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20745 assert!(snapshot.available);
20746 assert_eq!(snapshot.previous_version, Some(2));
20747 assert_eq!(snapshot.rows.len(), 1);
20748
20749 recapture_previous_version(&client);
20750 client.subs[0].bootstrap_state = None;
20751 assert!(client.previous_version_coverage_complete());
20752 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20753 assert_eq!(
20754 snapshot.reason,
20755 Some(PreviousVersionReason::CoverageComplete)
20756 );
20757 assert!(!std::path::Path::new(&container).exists());
20758 client.subs.clear();
20759
20760 assert!(!client.previous_version_coverage_complete());
20762
20763 drop(client);
20764 let _ = std::fs::remove_file(&path);
20765 }
20766
20767 #[test]
20774 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
20775 let path = std::env::temp_dir()
20776 .join(format!(
20777 "syncular-prev-lifetime-{}.db",
20778 uuid::Uuid::new_v4()
20779 ))
20780 .to_string_lossy()
20781 .into_owned();
20782 let container = format!(
20783 "{path}{}",
20784 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
20785 );
20786 let exists = || std::path::Path::new(&container).exists();
20787 {
20788 let mut setup = SyncClient::open_path(
20789 "lifetime".to_owned(),
20790 &previous_version_schema(1),
20791 previous_version_limits(),
20792 &path,
20793 )
20794 .expect("v1 install");
20795 setup
20796 .mutate(vec![Mutation::Upsert {
20797 table: "tasks".to_owned(),
20798 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
20799 base_version: None,
20800 }])
20801 .expect("seed a row");
20802 }
20803 let mut client = SyncClient::open_path(
20804 "lifetime".to_owned(),
20805 &previous_version_schema(2),
20806 previous_version_limits(),
20807 &path,
20808 )
20809 .expect("v2 bump captures");
20810 client.set_now_ms(1_000);
20813 assert!(exists());
20814
20815 client.lease_state = Some(LeaseState {
20817 lease_id: None,
20818 expires_at_ms: None,
20819 error_code: Some("lease.expired".to_owned()),
20820 });
20821 client.previous_version_lifetime_check();
20822 assert!(!exists());
20823 client.lease_state = None;
20824
20825 recapture_previous_version(&client);
20828 client.subs.push(subscription(SubState::Active, 0, None));
20829 client.previous_version_lifetime_check();
20830 assert!(!exists());
20831 client.subs.clear();
20832
20833 recapture_previous_version(&client);
20835 client.subs.push(subscription(SubState::Revoked, 0, None));
20836 client.previous_version_lifetime_check();
20837 assert!(!exists());
20838 client.subs.clear();
20839
20840 recapture_previous_version(&client);
20843 client
20844 .subs
20845 .push(subscription(SubState::Active, 0, Some("resume")));
20846 client.previous_version_lifetime_check();
20847 assert!(exists());
20848 client.subs.clear();
20849
20850 drop(client);
20851 let _ = std::fs::remove_file(&path);
20852 let _ = std::fs::remove_file(&container);
20853 let _ = std::fs::remove_file(format!("{container}-wal"));
20854 let _ = std::fs::remove_file(format!("{container}-shm"));
20855 }
20856}